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:
@@ -61,6 +61,9 @@ VAPID_PRIVATE_KEY=
|
|||||||
GITEA_URL=
|
GITEA_URL=
|
||||||
GITEA_TOKEN=
|
GITEA_TOKEN=
|
||||||
GITEA_REPO=owner/repo
|
GITEA_REPO=owner/repo
|
||||||
|
# Set as the webhook secret on the Gitea repo's webhook config (issues + issue_comment
|
||||||
|
# events) to sync issue close/reopen/comments back into Epicure support tickets.
|
||||||
|
GITEA_WEBHOOK_SECRET=
|
||||||
|
|
||||||
# Stripe (optional — webhook stub only)
|
# Stripe (optional — webhook stub only)
|
||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
||||||
|
|
||||||
|
## 0.63.0 — 2026-07-21 00:15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Support tickets now sync two-way with their linked Gitea issue: closing/reopening the issue in Gitea updates the ticket's status, and comments on either side (Epicure or Gitea) show up in both places. Configure a webhook secret (`GITEA_WEBHOOK_SECRET`) on the Gitea repo's webhook, subscribed to Issues and Issue Comment events, pointed at `/api/webhooks/gitea`.
|
||||||
|
|
||||||
## 0.62.0 — 2026-07-20 23:45
|
## 0.62.0 — 2026-07-20 23:45
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const ALLOWED_KEYS: SiteSettingKey[] = [
|
|||||||
"GITEA_URL",
|
"GITEA_URL",
|
||||||
"GITEA_TOKEN",
|
"GITEA_TOKEN",
|
||||||
"GITEA_REPO",
|
"GITEA_REPO",
|
||||||
|
"GITEA_WEBHOOK_SECRET",
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function PUT(req: NextRequest) {
|
export async function PUT(req: NextRequest) {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, supportTickets, supportTicketComments, eq, asc } from "@epicure/db";
|
||||||
|
import { requireAdmin } from "@/lib/api-auth";
|
||||||
|
import { postGiteaComment } from "@/lib/gitea";
|
||||||
|
|
||||||
|
const CreateCommentBody = z.object({
|
||||||
|
body: z.string().trim().min(1).max(3000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { response } = await requireAdmin();
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const comments = await db.query.supportTicketComments.findMany({
|
||||||
|
where: eq(supportTicketComments.ticketId, id),
|
||||||
|
orderBy: asc(supportTicketComments.createdAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(comments);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { session, response } = await requireAdmin();
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const ticket = await db.query.supportTickets.findFirst({ where: eq(supportTickets.id, id) });
|
||||||
|
if (!ticket) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const parsed = CreateCommentBody.safeParse(await req.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let giteaCommentId: number | null = null;
|
||||||
|
if (ticket.giteaIssueNumber) {
|
||||||
|
const { id: commentId } = await postGiteaComment(ticket.giteaIssueNumber, parsed.data.body);
|
||||||
|
giteaCommentId = commentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentId = crypto.randomUUID();
|
||||||
|
const now = new Date();
|
||||||
|
await db.insert(supportTicketComments).values({
|
||||||
|
id: commentId,
|
||||||
|
ticketId: id,
|
||||||
|
authorType: "admin",
|
||||||
|
authorId: session!.user.id,
|
||||||
|
body: parsed.data.body,
|
||||||
|
giteaCommentId,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ id: commentId, ticketId: id, authorType: "admin", authorId: session!.user.id, body: parsed.data.body, createdAt: now.toISOString() },
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, supportTickets, supportTicketAttachments, eq } from "@epicure/db";
|
import { db, supportTickets, supportTicketAttachments, eq } from "@epicure/db";
|
||||||
import { requireAdmin } from "@/lib/api-auth";
|
import { requireAdmin } from "@/lib/api-auth";
|
||||||
import { createGiteaIssue, buildGiteaIssueBody } from "@/lib/gitea";
|
import { createGiteaIssue, buildGiteaIssueBody, setGiteaIssueState } from "@/lib/gitea";
|
||||||
|
|
||||||
const UpdateTicketBody = z.object({
|
const UpdateTicketBody = z.object({
|
||||||
status: z.enum(["open", "triaged", "closed"]).optional(),
|
status: z.enum(["open", "triaged", "closed"]).optional(),
|
||||||
@@ -27,6 +27,11 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
|
|||||||
|
|
||||||
if (parsed.data.status) {
|
if (parsed.data.status) {
|
||||||
updates.status = parsed.data.status;
|
updates.status = parsed.data.status;
|
||||||
|
// Mirror the status change onto the linked Gitea issue — best-effort,
|
||||||
|
// never blocks the ticket update on Gitea being reachable.
|
||||||
|
if (ticket.giteaIssueNumber && parsed.data.status !== ticket.status) {
|
||||||
|
void setGiteaIssueState(ticket.giteaIssueNumber, parsed.data.status === "closed" ? "closed" : "open");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.data.retryGitea) {
|
if (parsed.data.retryGitea) {
|
||||||
@@ -35,12 +40,13 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
|
|||||||
.from(supportTicketAttachments)
|
.from(supportTicketAttachments)
|
||||||
.where(eq(supportTicketAttachments.ticketId, id));
|
.where(eq(supportTicketAttachments.ticketId, id));
|
||||||
|
|
||||||
const { url, error } = await createGiteaIssue({
|
const { url, number, error } = await createGiteaIssue({
|
||||||
type: ticket.type,
|
type: ticket.type,
|
||||||
title: ticket.title,
|
title: ticket.title,
|
||||||
body: buildGiteaIssueBody({ description: ticket.description, userId: ticket.userId, attachments }),
|
body: buildGiteaIssueBody({ description: ticket.description, userId: ticket.userId, attachments }),
|
||||||
});
|
});
|
||||||
updates.giteaIssueUrl = url;
|
updates.giteaIssueUrl = url;
|
||||||
|
updates.giteaIssueNumber = number;
|
||||||
updates.giteaError = error;
|
updates.giteaError = error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, supportTickets, supportTicketComments, eq, asc } from "@epicure/db";
|
||||||
|
import { requireSession } from "@/lib/api-auth";
|
||||||
|
import { postGiteaComment } from "@/lib/gitea";
|
||||||
|
|
||||||
|
const CreateCommentBody = z.object({
|
||||||
|
body: z.string().trim().min(1).max(3000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const ticket = await db.query.supportTickets.findFirst({ where: eq(supportTickets.id, id) });
|
||||||
|
if (!ticket || ticket.userId !== session!.user.id) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = await db.query.supportTicketComments.findMany({
|
||||||
|
where: eq(supportTicketComments.ticketId, id),
|
||||||
|
orderBy: asc(supportTicketComments.createdAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(comments);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const ticket = await db.query.supportTickets.findFirst({ where: eq(supportTickets.id, id) });
|
||||||
|
if (!ticket || ticket.userId !== session!.user.id) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = CreateCommentBody.safeParse(await req.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let giteaCommentId: number | null = null;
|
||||||
|
if (ticket.giteaIssueNumber) {
|
||||||
|
const { id: commentId } = await postGiteaComment(ticket.giteaIssueNumber, parsed.data.body);
|
||||||
|
giteaCommentId = commentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentId = crypto.randomUUID();
|
||||||
|
const now = new Date();
|
||||||
|
await db.insert(supportTicketComments).values({
|
||||||
|
id: commentId,
|
||||||
|
ticketId: id,
|
||||||
|
authorType: "user",
|
||||||
|
authorId: session!.user.id,
|
||||||
|
body: parsed.data.body,
|
||||||
|
giteaCommentId,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ id: commentId, ticketId: id, authorType: "user", authorId: session!.user.id, body: parsed.data.body, createdAt: now.toISOString() },
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@ export async function POST(req: NextRequest) {
|
|||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const { url: giteaIssueUrl, error: giteaError } = await createGiteaIssue({
|
const { url: giteaIssueUrl, number: giteaIssueNumber, error: giteaError } = await createGiteaIssue({
|
||||||
type,
|
type,
|
||||||
title,
|
title,
|
||||||
body: buildGiteaIssueBody({ description, userId: session!.user.id, attachments }),
|
body: buildGiteaIssueBody({ description, userId: session!.user.id, attachments }),
|
||||||
@@ -80,6 +80,7 @@ export async function POST(req: NextRequest) {
|
|||||||
description,
|
description,
|
||||||
status: "open",
|
status: "open",
|
||||||
giteaIssueUrl,
|
giteaIssueUrl,
|
||||||
|
giteaIssueNumber,
|
||||||
giteaError,
|
giteaError,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { db, supportTickets, supportTicketComments, processedGiteaEvents, eq } from "@epicure/db";
|
||||||
|
import { getSiteSetting } from "@/lib/site-settings";
|
||||||
|
import { verifyGiteaSignature } from "@/lib/gitea";
|
||||||
|
|
||||||
|
type GiteaIssuePayload = {
|
||||||
|
action?: string;
|
||||||
|
number?: number;
|
||||||
|
issue?: { number?: number };
|
||||||
|
comment?: { id?: number; body?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const secret = await getSiteSetting("GITEA_WEBHOOK_SECRET");
|
||||||
|
if (!secret) {
|
||||||
|
return NextResponse.json({ error: "Gitea webhook not configured" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = await req.text();
|
||||||
|
const signature = req.headers.get("x-gitea-signature");
|
||||||
|
if (!signature || !verifyGiteaSignature(rawBody, signature, secret)) {
|
||||||
|
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gitea includes a per-delivery id that stays stable across redeliveries
|
||||||
|
// of the same event, same dedupe pattern as the Stripe receiver.
|
||||||
|
const deliveryId = req.headers.get("x-gitea-delivery");
|
||||||
|
if (deliveryId) {
|
||||||
|
const [inserted] = await db
|
||||||
|
.insert(processedGiteaEvents)
|
||||||
|
.values({ id: deliveryId })
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning({ id: processedGiteaEvents.id });
|
||||||
|
if (!inserted) {
|
||||||
|
return NextResponse.json({ received: true, duplicate: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventType = req.headers.get("x-gitea-event");
|
||||||
|
let payload: GiteaIssuePayload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(rawBody) as GiteaIssuePayload;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueNumber = payload.issue?.number ?? payload.number;
|
||||||
|
if (!issueNumber) {
|
||||||
|
return NextResponse.json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticket = await db.query.supportTickets.findFirst({ where: eq(supportTickets.giteaIssueNumber, issueNumber) });
|
||||||
|
if (!ticket) {
|
||||||
|
return NextResponse.json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === "issues") {
|
||||||
|
if (payload.action === "closed" && ticket.status !== "closed") {
|
||||||
|
await db.update(supportTickets).set({ status: "closed", updatedAt: new Date() }).where(eq(supportTickets.id, ticket.id));
|
||||||
|
} else if (payload.action === "reopened" && ticket.status === "closed") {
|
||||||
|
await db.update(supportTickets).set({ status: "open", updatedAt: new Date() }).where(eq(supportTickets.id, ticket.id));
|
||||||
|
}
|
||||||
|
} else if (eventType === "issue_comment" && payload.action === "created" && payload.comment?.body) {
|
||||||
|
const giteaCommentId = payload.comment.id ?? null;
|
||||||
|
// A comment Epicure just posted via the API already has a row here
|
||||||
|
// (inserted synchronously when it was sent) — skip it so the webhook
|
||||||
|
// delivery for our own comment doesn't create a duplicate.
|
||||||
|
const existing = giteaCommentId
|
||||||
|
? await db.query.supportTicketComments.findFirst({ where: eq(supportTicketComments.giteaCommentId, giteaCommentId) })
|
||||||
|
: null;
|
||||||
|
if (!existing) {
|
||||||
|
await db.insert(supportTicketComments).values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
ticketId: ticket.id,
|
||||||
|
authorType: "gitea",
|
||||||
|
authorId: null,
|
||||||
|
body: payload.comment.body,
|
||||||
|
giteaCommentId,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ received: true });
|
||||||
|
}
|
||||||
@@ -4,9 +4,10 @@ import { useState } from "react";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ExternalLink, FileText } from "lucide-react";
|
import { ExternalLink, FileText, MessageSquare } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -19,6 +20,10 @@ type TicketStatus = "open" | "triaged" | "closed";
|
|||||||
|
|
||||||
type Attachment = { id: string; contentType: string; url: string };
|
type Attachment = { id: string; contentType: string; url: string };
|
||||||
|
|
||||||
|
type Comment = { id: string; authorType: "user" | "admin" | "gitea"; body: string; createdAt: string };
|
||||||
|
|
||||||
|
const AUTHOR_LABEL: Record<Comment["authorType"], string> = { user: "User", admin: "Admin", gitea: "Gitea" };
|
||||||
|
|
||||||
type Ticket = {
|
type Ticket = {
|
||||||
id: string;
|
id: string;
|
||||||
userEmail: string;
|
userEmail: string;
|
||||||
@@ -55,6 +60,51 @@ const statusVariant: Record<TicketStatus, "default" | "secondary" | "outline"> =
|
|||||||
export function AdminSupportManager({ initialTickets }: { initialTickets: Ticket[] }) {
|
export function AdminSupportManager({ initialTickets }: { initialTickets: Ticket[] }) {
|
||||||
const [tickets, setTickets] = useState<Ticket[]>(initialTickets);
|
const [tickets, setTickets] = useState<Ticket[]>(initialTickets);
|
||||||
const [retrying, setRetrying] = useState<string | null>(null);
|
const [retrying, setRetrying] = useState<string | null>(null);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [comments, setComments] = useState<Record<string, Comment[]>>({});
|
||||||
|
const [commentDraft, setCommentDraft] = useState("");
|
||||||
|
const [sendingComment, setSendingComment] = useState(false);
|
||||||
|
|
||||||
|
async function toggleConversation(ticketId: string) {
|
||||||
|
if (expandedId === ticketId) {
|
||||||
|
setExpandedId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExpandedId(ticketId);
|
||||||
|
setCommentDraft("");
|
||||||
|
if (!comments[ticketId]) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/admin/support/${ticketId}/comments`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as Comment[];
|
||||||
|
setComments((prev) => ({ ...prev, [ticketId]: data }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// leave the section empty — the send box below still works
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendComment(ticketId: string) {
|
||||||
|
const body = commentDraft.trim();
|
||||||
|
if (!body) return;
|
||||||
|
setSendingComment(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/admin/support/${ticketId}/comments`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ body }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const comment = (await res.json()) as Comment;
|
||||||
|
setComments((prev) => ({ ...prev, [ticketId]: [...(prev[ticketId] ?? []), comment] }));
|
||||||
|
setCommentDraft("");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to send comment");
|
||||||
|
} finally {
|
||||||
|
setSendingComment(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleStatusChange(id: string, status: TicketStatus) {
|
async function handleStatusChange(id: string, status: TicketStatus) {
|
||||||
try {
|
try {
|
||||||
@@ -172,7 +222,50 @@ export function AdminSupportManager({ initialTickets }: { initialTickets: Ticket
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { void toggleConversation(ticket.id); }}
|
||||||
|
className="flex items-center gap-1 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-3 w-3" />
|
||||||
|
Conversation
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{expandedId === ticket.id && (
|
||||||
|
<div className="mt-2 space-y-2 rounded-md border bg-muted/30 p-3">
|
||||||
|
{(comments[ticket.id] ?? []).length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">No comments yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(comments[ticket.id] ?? []).map((c) => (
|
||||||
|
<div key={c.id} className="text-sm">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">{AUTHOR_LABEL[c.authorType]}</span>
|
||||||
|
<p className="whitespace-pre-wrap">{c.body}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<Textarea
|
||||||
|
value={commentDraft}
|
||||||
|
onChange={(e) => setCommentDraft(e.target.value)}
|
||||||
|
placeholder="Reply to this ticket…"
|
||||||
|
maxLength={3000}
|
||||||
|
rows={2}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={sendingComment || !commentDraft.trim()}
|
||||||
|
onClick={() => { void sendComment(ticket.id); }}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ExternalLink, Paperclip, X, FileText } from "lucide-react";
|
import { ExternalLink, Paperclip, X, FileText, MessageSquare } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -25,6 +25,8 @@ type TicketStatus = "open" | "triaged" | "closed";
|
|||||||
|
|
||||||
type Attachment = { id: string; contentType: string; url: string };
|
type Attachment = { id: string; contentType: string; url: string };
|
||||||
|
|
||||||
|
type Comment = { id: string; authorType: "user" | "admin" | "gitea"; body: string; createdAt: string };
|
||||||
|
|
||||||
type Ticket = {
|
type Ticket = {
|
||||||
id: string;
|
id: string;
|
||||||
type: TicketType;
|
type: TicketType;
|
||||||
@@ -76,6 +78,51 @@ export function SupportManager({
|
|||||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||||
const [uploadingCount, setUploadingCount] = useState(0);
|
const [uploadingCount, setUploadingCount] = useState(0);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [comments, setComments] = useState<Record<string, Comment[]>>({});
|
||||||
|
const [commentDraft, setCommentDraft] = useState("");
|
||||||
|
const [sendingComment, setSendingComment] = useState(false);
|
||||||
|
|
||||||
|
async function toggleConversation(ticketId: string) {
|
||||||
|
if (expandedId === ticketId) {
|
||||||
|
setExpandedId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExpandedId(ticketId);
|
||||||
|
setCommentDraft("");
|
||||||
|
if (!comments[ticketId]) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/support/${ticketId}/comments`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as Comment[];
|
||||||
|
setComments((prev) => ({ ...prev, [ticketId]: data }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// leave the section empty — the send box below still works
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendComment(ticketId: string) {
|
||||||
|
const body = commentDraft.trim();
|
||||||
|
if (!body) return;
|
||||||
|
setSendingComment(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/support/${ticketId}/comments`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ body }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const comment = (await res.json()) as Comment;
|
||||||
|
setComments((prev) => ({ ...prev, [ticketId]: [...(prev[ticketId] ?? []), comment] }));
|
||||||
|
setCommentDraft("");
|
||||||
|
} catch {
|
||||||
|
toast.error(t("commentSendFailed"));
|
||||||
|
} finally {
|
||||||
|
setSendingComment(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleFiles(files: FileList) {
|
async function handleFiles(files: FileList) {
|
||||||
const room = MAX_ATTACHMENTS - pendingAttachments.length;
|
const room = MAX_ATTACHMENTS - pendingAttachments.length;
|
||||||
@@ -315,7 +362,52 @@ export function SupportManager({
|
|||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { void toggleConversation(ticket.id); }}
|
||||||
|
className="flex items-center gap-1 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-3 w-3" />
|
||||||
|
{t("conversation")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{expandedId === ticket.id && (
|
||||||
|
<div className="mt-2 space-y-2 rounded-md border bg-muted/30 p-3">
|
||||||
|
{(comments[ticket.id] ?? []).length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t("noComments")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(comments[ticket.id] ?? []).map((c) => (
|
||||||
|
<div key={c.id} className="text-sm">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
{t(`commentAuthor.${c.authorType}`)}
|
||||||
|
</span>
|
||||||
|
<p className="whitespace-pre-wrap">{c.body}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<Textarea
|
||||||
|
value={commentDraft}
|
||||||
|
onChange={(e) => setCommentDraft(e.target.value)}
|
||||||
|
placeholder={t("commentPlaceholder")}
|
||||||
|
maxLength={3000}
|
||||||
|
rows={2}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={sendingComment || !commentDraft.trim()}
|
||||||
|
onClick={() => { void sendComment(ticket.id); }}
|
||||||
|
>
|
||||||
|
{t("commentSend")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||||
export const APP_VERSION = "0.62.0";
|
export const APP_VERSION = "0.63.0";
|
||||||
|
|
||||||
export type ChangelogEntry = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.63.0",
|
||||||
|
date: "2026-07-21 00:15",
|
||||||
|
added: [
|
||||||
|
"Support tickets now sync two-way with their linked Gitea issue: closing/reopening the issue in Gitea updates the ticket's status, and comments on either side (Epicure or Gitea) show up in both places. Configure a webhook secret (GITEA_WEBHOOK_SECRET) on the Gitea repo's webhook, subscribed to Issues and Issue Comment events, pointed at /api/webhooks/gitea.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.62.0",
|
version: "0.62.0",
|
||||||
date: "2026-07-20 23:45",
|
date: "2026-07-20 23:45",
|
||||||
|
|||||||
+93
-6
@@ -1,3 +1,4 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
import { getSiteSetting } from "@/lib/site-settings";
|
import { getSiteSetting } from "@/lib/site-settings";
|
||||||
import { getPublicUrl } from "@/lib/storage";
|
import { getPublicUrl } from "@/lib/storage";
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ export async function createGiteaIssue(opts: {
|
|||||||
type: string;
|
type: string;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
}): Promise<{ url: string | null; error: string | null }> {
|
}): Promise<{ url: string | null; number: number | null; error: string | null }> {
|
||||||
const [rawBaseUrl, token, repo] = await Promise.all([
|
const [rawBaseUrl, token, repo] = await Promise.all([
|
||||||
getSiteSetting("GITEA_URL"),
|
getSiteSetting("GITEA_URL"),
|
||||||
getSiteSetting("GITEA_TOKEN"),
|
getSiteSetting("GITEA_TOKEN"),
|
||||||
@@ -66,7 +67,7 @@ export async function createGiteaIssue(opts: {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!rawBaseUrl || !token || !repo) {
|
if (!rawBaseUrl || !token || !repo) {
|
||||||
return { url: null, error: null };
|
return { url: null, number: null, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = rawBaseUrl.replace(/\/$/, "");
|
const baseUrl = rawBaseUrl.replace(/\/$/, "");
|
||||||
@@ -90,11 +91,15 @@ export async function createGiteaIssue(opts: {
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
return { url: null, error: `Gitea returned ${res.status}: ${text.slice(0, 300)}` };
|
return { url: null, number: null, error: `Gitea returned ${res.status}: ${text.slice(0, 300)}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const issue = (await res.json()) as { html_url?: string };
|
const issue = (await res.json()) as { html_url?: string; number?: number };
|
||||||
return { url: issue.html_url ?? null, error: issue.html_url ? null : "Gitea response had no html_url" };
|
return {
|
||||||
|
url: issue.html_url ?? null,
|
||||||
|
number: issue.number ?? null,
|
||||||
|
error: issue.html_url ? null : "Gitea response had no html_url",
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A thrown fetch error (DNS failure, connection refused, timeout, TLS
|
// A thrown fetch error (DNS failure, connection refused, timeout, TLS
|
||||||
// error) never reaches the res.ok branch above, so its real cause would
|
// error) never reaches the res.ok branch above, so its real cause would
|
||||||
@@ -105,6 +110,88 @@ export async function createGiteaIssue(opts: {
|
|||||||
const cause = err instanceof Error && err.cause instanceof Error ? err.cause.message : undefined;
|
const cause = err instanceof Error && err.cause instanceof Error ? err.cause.message : undefined;
|
||||||
const message = err instanceof Error ? err.message : "Unknown error";
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
console.error("[gitea] createGiteaIssue failed", { baseUrl, repo, message, cause });
|
console.error("[gitea] createGiteaIssue failed", { baseUrl, repo, message, cause });
|
||||||
return { url: null, error: cause ? `${message}: ${cause}` : message };
|
return { url: null, number: null, error: cause ? `${message}: ${cause}` : message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function giteaCreds(): Promise<{ baseUrl: string; token: string; repo: string } | null> {
|
||||||
|
const [rawBaseUrl, token, repo] = await Promise.all([
|
||||||
|
getSiteSetting("GITEA_URL"),
|
||||||
|
getSiteSetting("GITEA_TOKEN"),
|
||||||
|
getSiteSetting("GITEA_REPO"),
|
||||||
|
]);
|
||||||
|
if (!rawBaseUrl || !token || !repo) return null;
|
||||||
|
return { baseUrl: rawBaseUrl.replace(/\/$/, ""), token, repo };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Posts a comment on the linked Gitea issue. Best-effort — callers must not
|
||||||
|
* block ticket/comment saving on this succeeding. */
|
||||||
|
export async function postGiteaComment(
|
||||||
|
issueNumber: number,
|
||||||
|
body: string
|
||||||
|
): Promise<{ id: number | null; error: string | null }> {
|
||||||
|
const creds = await giteaCreds();
|
||||||
|
if (!creds) return { id: null, error: null };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${creds.baseUrl}/api/v1/repos/${creds.repo}/issues/${issueNumber}/comments`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `token ${creds.token}` },
|
||||||
|
body: JSON.stringify({ body }),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
return { id: null, error: `Gitea returned ${res.status}: ${text.slice(0, 300)}` };
|
||||||
|
}
|
||||||
|
const comment = (await res.json()) as { id?: number };
|
||||||
|
return { id: comment.id ?? null, error: null };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
console.error("[gitea] postGiteaComment failed", { issueNumber, message });
|
||||||
|
return { id: null, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens or closes the linked Gitea issue to mirror a ticket status change.
|
||||||
|
* Best-effort, same reasoning as postGiteaComment. */
|
||||||
|
export async function setGiteaIssueState(
|
||||||
|
issueNumber: number,
|
||||||
|
state: "open" | "closed"
|
||||||
|
): Promise<{ ok: boolean; error: string | null }> {
|
||||||
|
const creds = await giteaCreds();
|
||||||
|
if (!creds) return { ok: false, error: null };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${creds.baseUrl}/api/v1/repos/${creds.repo}/issues/${issueNumber}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `token ${creds.token}` },
|
||||||
|
body: JSON.stringify({ state }),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
return { ok: false, error: `Gitea returned ${res.status}: ${text.slice(0, 300)}` };
|
||||||
|
}
|
||||||
|
return { ok: true, error: null };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
console.error("[gitea] setGiteaIssueState failed", { issueNumber, state, message });
|
||||||
|
return { ok: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gitea signs webhook deliveries with HMAC-SHA256 hex digest of the raw
|
||||||
|
* body (X-Gitea-Signature) — no timestamp prefix, unlike Stripe. */
|
||||||
|
export function verifyGiteaSignature(rawBody: string, signature: string, secret: string): boolean {
|
||||||
|
const expected = crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
|
||||||
|
const expectedBuf = Buffer.from(expected, "hex");
|
||||||
|
let providedBuf: Buffer;
|
||||||
|
try {
|
||||||
|
providedBuf = Buffer.from(signature, "hex");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (providedBuf.length !== expectedBuf.length) return false;
|
||||||
|
return crypto.timingSafeEqual(expectedBuf, providedBuf);
|
||||||
|
}
|
||||||
|
|||||||
+16
-2
@@ -646,7 +646,8 @@ export function generateOpenApiSpec(): object {
|
|||||||
}));
|
}));
|
||||||
const SupportTicketRef = registry.register("SupportTicket", z.object({
|
const SupportTicketRef = registry.register("SupportTicket", z.object({
|
||||||
id: z.string(), userId: z.string(), type: SupportTicketTypeEnum, title: z.string(), description: z.string(),
|
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(),
|
attachments: z.array(SupportAttachmentRef), createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
|
||||||
}));
|
}));
|
||||||
const CreateSupportTicketRef = registry.register("CreateSupportTicket", z.object({
|
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", 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 } } } } });
|
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({
|
const AdminSupportTicketRef = registry.register("AdminSupportTicket", z.object({
|
||||||
id: z.string(), userId: z.string(), userEmail: z.string(), username: z.string().nullable(),
|
id: z.string(), userId: z.string(), userEmail: z.string(), username: z.string().nullable(),
|
||||||
type: SupportTicketTypeEnum, title: z.string(), description: z.string(),
|
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/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: "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: "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 } } } } });
|
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 } } } } });
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ export type SiteSettingKey =
|
|||||||
| "AI_TOOL_CALLING_ENABLED"
|
| "AI_TOOL_CALLING_ENABLED"
|
||||||
| "GITEA_URL"
|
| "GITEA_URL"
|
||||||
| "GITEA_TOKEN"
|
| "GITEA_TOKEN"
|
||||||
| "GITEA_REPO";
|
| "GITEA_REPO"
|
||||||
|
| "GITEA_WEBHOOK_SECRET";
|
||||||
|
|
||||||
const SECRET_KEYS: SiteSettingKey[] = [
|
const SECRET_KEYS: SiteSettingKey[] = [
|
||||||
"OPENAI_API_KEY",
|
"OPENAI_API_KEY",
|
||||||
@@ -29,6 +30,7 @@ const SECRET_KEYS: SiteSettingKey[] = [
|
|||||||
"OPENROUTER_API_KEY",
|
"OPENROUTER_API_KEY",
|
||||||
"VAPID_PRIVATE_KEY",
|
"VAPID_PRIVATE_KEY",
|
||||||
"GITEA_TOKEN",
|
"GITEA_TOKEN",
|
||||||
|
"GITEA_WEBHOOK_SECRET",
|
||||||
];
|
];
|
||||||
|
|
||||||
export function isSecretKey(key: SiteSettingKey): boolean {
|
export function isSecretKey(key: SiteSettingKey): boolean {
|
||||||
@@ -71,6 +73,7 @@ export async function getAllSiteSettings(): Promise<Record<string, { value: stri
|
|||||||
"GITEA_URL",
|
"GITEA_URL",
|
||||||
"GITEA_TOKEN",
|
"GITEA_TOKEN",
|
||||||
"GITEA_REPO",
|
"GITEA_REPO",
|
||||||
|
"GITEA_WEBHOOK_SECRET",
|
||||||
];
|
];
|
||||||
|
|
||||||
const result: Record<string, { value: string | null; isSecret: boolean; fromDb: boolean }> = {};
|
const result: Record<string, { value: string | null; isSecret: boolean; fromDb: boolean }> = {};
|
||||||
|
|||||||
@@ -492,6 +492,16 @@
|
|||||||
"historyTitle": "Your tickets",
|
"historyTitle": "Your tickets",
|
||||||
"noTickets": "You haven't submitted anything yet.",
|
"noTickets": "You haven't submitted anything yet.",
|
||||||
"viewIssue": "View issue",
|
"viewIssue": "View issue",
|
||||||
|
"conversation": "Conversation",
|
||||||
|
"noComments": "No replies yet.",
|
||||||
|
"commentPlaceholder": "Write a reply…",
|
||||||
|
"commentSend": "Send",
|
||||||
|
"commentSendFailed": "Failed to send reply",
|
||||||
|
"commentAuthor": {
|
||||||
|
"user": "You",
|
||||||
|
"admin": "Epicure team",
|
||||||
|
"gitea": "Epicure team"
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"open": "Open",
|
"open": "Open",
|
||||||
"triaged": "Triaged",
|
"triaged": "Triaged",
|
||||||
|
|||||||
@@ -492,6 +492,16 @@
|
|||||||
"historyTitle": "Vos tickets",
|
"historyTitle": "Vos tickets",
|
||||||
"noTickets": "Vous n'avez encore rien soumis.",
|
"noTickets": "Vous n'avez encore rien soumis.",
|
||||||
"viewIssue": "Voir le ticket",
|
"viewIssue": "Voir le ticket",
|
||||||
|
"conversation": "Conversation",
|
||||||
|
"noComments": "Aucune réponse pour l'instant.",
|
||||||
|
"commentPlaceholder": "Écrire une réponse…",
|
||||||
|
"commentSend": "Envoyer",
|
||||||
|
"commentSendFailed": "Échec de l'envoi de la réponse",
|
||||||
|
"commentAuthor": {
|
||||||
|
"user": "Vous",
|
||||||
|
"admin": "Équipe Epicure",
|
||||||
|
"gitea": "Équipe Epicure"
|
||||||
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"open": "Ouvert",
|
"open": "Ouvert",
|
||||||
"triaged": "Trié",
|
"triaged": "Trié",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.62.0",
|
"version": "0.63.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.62.0",
|
"version": "0.63.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
@@ -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,
|
"when": 1784582403171,
|
||||||
"tag": "0057_sudden_kree",
|
"tag": "0057_sudden_kree",
|
||||||
"breakpoints": true
|
"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 supportTicketTypeEnum = pgEnum("support_ticket_type", ["bug", "suggestion", "question"]);
|
||||||
export const supportTicketStatusEnum = pgEnum("support_ticket_status", ["open", "triaged", "closed"]);
|
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", {
|
export const supportTickets = pgTable("support_tickets", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
@@ -13,14 +14,42 @@ export const supportTickets = pgTable("support_tickets", {
|
|||||||
description: text("description").notNull(),
|
description: text("description").notNull(),
|
||||||
status: supportTicketStatusEnum("status").notNull().default("open"),
|
status: supportTicketStatusEnum("status").notNull().default("open"),
|
||||||
giteaIssueUrl: text("gitea_issue_url"),
|
giteaIssueUrl: text("gitea_issue_url"),
|
||||||
|
giteaIssueNumber: integer("gitea_issue_number"),
|
||||||
giteaError: text("gitea_error"),
|
giteaError: text("gitea_error"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
}, (t) => [
|
}, (t) => [
|
||||||
index("support_tickets_user_idx").on(t.userId, t.createdAt),
|
index("support_tickets_user_idx").on(t.userId, t.createdAt),
|
||||||
index("support_tickets_status_idx").on(t.status, 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", {
|
export const supportTicketAttachments = pgTable("support_ticket_attachments", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
ticketId: text("ticket_id").notNull().references(() => supportTickets.id, { onDelete: "cascade" }),
|
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 }) => ({
|
export const supportTicketsRelations = relations(supportTickets, ({ one, many }) => ({
|
||||||
user: one(users, { fields: [supportTickets.userId], references: [users.id] }),
|
user: one(users, { fields: [supportTickets.userId], references: [users.id] }),
|
||||||
attachments: many(supportTicketAttachments),
|
attachments: many(supportTicketAttachments),
|
||||||
|
comments: many(supportTicketComments),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const supportTicketAttachmentsRelations = relations(supportTicketAttachments, ({ one }) => ({
|
export const supportTicketAttachmentsRelations = relations(supportTicketAttachments, ({ one }) => ({
|
||||||
ticket: one(supportTickets, { fields: [supportTicketAttachments.ticketId], references: [supportTickets.id] }),
|
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