diff --git a/.env.example b/.env.example index c8ea439..1737fab 100644 --- a/.env.example +++ b/.env.example @@ -61,6 +61,9 @@ VAPID_PRIVATE_KEY= GITEA_URL= GITEA_TOKEN= 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_WEBHOOK_SECRET= diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e19098..7000b7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +## 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 ### Added diff --git a/apps/web/app/api/v1/admin/settings/route.ts b/apps/web/app/api/v1/admin/settings/route.ts index cc9b0ed..1b5cc97 100644 --- a/apps/web/app/api/v1/admin/settings/route.ts +++ b/apps/web/app/api/v1/admin/settings/route.ts @@ -25,6 +25,7 @@ const ALLOWED_KEYS: SiteSettingKey[] = [ "GITEA_URL", "GITEA_TOKEN", "GITEA_REPO", + "GITEA_WEBHOOK_SECRET", ]; export async function PUT(req: NextRequest) { diff --git a/apps/web/app/api/v1/admin/support/[id]/comments/route.ts b/apps/web/app/api/v1/admin/support/[id]/comments/route.ts new file mode 100644 index 0000000..9cad537 --- /dev/null +++ b/apps/web/app/api/v1/admin/support/[id]/comments/route.ts @@ -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 } + ); +} diff --git a/apps/web/app/api/v1/admin/support/[id]/route.ts b/apps/web/app/api/v1/admin/support/[id]/route.ts index ee33593..f3a35f2 100644 --- a/apps/web/app/api/v1/admin/support/[id]/route.ts +++ b/apps/web/app/api/v1/admin/support/[id]/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { db, supportTickets, supportTicketAttachments, eq } from "@epicure/db"; import { requireAdmin } from "@/lib/api-auth"; -import { createGiteaIssue, buildGiteaIssueBody } from "@/lib/gitea"; +import { createGiteaIssue, buildGiteaIssueBody, setGiteaIssueState } from "@/lib/gitea"; const UpdateTicketBody = z.object({ 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) { 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) { @@ -35,12 +40,13 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id .from(supportTicketAttachments) .where(eq(supportTicketAttachments.ticketId, id)); - const { url, error } = await createGiteaIssue({ + const { url, number, error } = await createGiteaIssue({ type: ticket.type, title: ticket.title, body: buildGiteaIssueBody({ description: ticket.description, userId: ticket.userId, attachments }), }); updates.giteaIssueUrl = url; + updates.giteaIssueNumber = number; updates.giteaError = error; } diff --git a/apps/web/app/api/v1/support/[id]/comments/route.ts b/apps/web/app/api/v1/support/[id]/comments/route.ts new file mode 100644 index 0000000..27fe3a4 --- /dev/null +++ b/apps/web/app/api/v1/support/[id]/comments/route.ts @@ -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 } + ); +} diff --git a/apps/web/app/api/v1/support/route.ts b/apps/web/app/api/v1/support/route.ts index aa01b3f..1459932 100644 --- a/apps/web/app/api/v1/support/route.ts +++ b/apps/web/app/api/v1/support/route.ts @@ -66,7 +66,7 @@ export async function POST(req: NextRequest) { const id = crypto.randomUUID(); const now = new Date(); - const { url: giteaIssueUrl, error: giteaError } = await createGiteaIssue({ + const { url: giteaIssueUrl, number: giteaIssueNumber, error: giteaError } = await createGiteaIssue({ type, title, body: buildGiteaIssueBody({ description, userId: session!.user.id, attachments }), @@ -80,6 +80,7 @@ export async function POST(req: NextRequest) { description, status: "open", giteaIssueUrl, + giteaIssueNumber, giteaError, createdAt: now, updatedAt: now, diff --git a/apps/web/app/api/webhooks/gitea/route.ts b/apps/web/app/api/webhooks/gitea/route.ts new file mode 100644 index 0000000..2dcda91 --- /dev/null +++ b/apps/web/app/api/webhooks/gitea/route.ts @@ -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 }); +} diff --git a/apps/web/components/admin/admin-support-manager.tsx b/apps/web/components/admin/admin-support-manager.tsx index 39a91fe..3ba690b 100644 --- a/apps/web/components/admin/admin-support-manager.tsx +++ b/apps/web/components/admin/admin-support-manager.tsx @@ -4,9 +4,10 @@ import { useState } from "react"; import Image from "next/image"; import Link from "next/link"; import { toast } from "sonner"; -import { ExternalLink, FileText } from "lucide-react"; +import { ExternalLink, FileText, MessageSquare } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, @@ -19,6 +20,10 @@ type TicketStatus = "open" | "triaged" | "closed"; type Attachment = { id: string; contentType: string; url: string }; +type Comment = { id: string; authorType: "user" | "admin" | "gitea"; body: string; createdAt: string }; + +const AUTHOR_LABEL: Record = { user: "User", admin: "Admin", gitea: "Gitea" }; + type Ticket = { id: string; userEmail: string; @@ -55,6 +60,51 @@ const statusVariant: Record = export function AdminSupportManager({ initialTickets }: { initialTickets: Ticket[] }) { const [tickets, setTickets] = useState(initialTickets); const [retrying, setRetrying] = useState(null); + const [expandedId, setExpandedId] = useState(null); + const [comments, setComments] = useState>({}); + 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) { try { @@ -172,7 +222,50 @@ export function AdminSupportManager({ initialTickets }: { initialTickets: Ticket )} + + + {expandedId === ticket.id && ( +
+ {(comments[ticket.id] ?? []).length === 0 ? ( +

No comments yet.

+ ) : ( +
+ {(comments[ticket.id] ?? []).map((c) => ( +
+ {AUTHOR_LABEL[c.authorType]} +

{c.body}

+
+ ))} +
+ )} +
+