b17984fbef
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>
274 lines
9.6 KiB
TypeScript
274 lines
9.6 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { toast } from "sonner";
|
|
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,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
|
|
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<Comment["authorType"], string> = { user: "User", admin: "Admin", gitea: "Gitea" };
|
|
|
|
type Ticket = {
|
|
id: string;
|
|
userEmail: string;
|
|
username: string | null;
|
|
type: string;
|
|
title: string;
|
|
description: string;
|
|
status: TicketStatus;
|
|
giteaIssueUrl: string | null;
|
|
giteaError: string | null;
|
|
createdAt: string;
|
|
attachments: Attachment[];
|
|
};
|
|
|
|
function isImage(contentType: string) {
|
|
return contentType.startsWith("image/");
|
|
}
|
|
|
|
function formatDateTime(iso: string) {
|
|
return new Date(iso).toLocaleString(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
}
|
|
|
|
const statusVariant: Record<TicketStatus, "default" | "secondary" | "outline"> = {
|
|
open: "default",
|
|
triaged: "secondary",
|
|
closed: "outline",
|
|
};
|
|
|
|
export function AdminSupportManager({ initialTickets }: { initialTickets: Ticket[] }) {
|
|
const [tickets, setTickets] = useState<Ticket[]>(initialTickets);
|
|
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) {
|
|
try {
|
|
const res = await fetch(`/api/v1/admin/support/${id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ status }),
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
setTickets((prev) => prev.map((t) => (t.id === id ? { ...t, status } : t)));
|
|
} catch {
|
|
toast.error("Failed to update status");
|
|
}
|
|
}
|
|
|
|
async function handleRetryGitea(id: string) {
|
|
setRetrying(id);
|
|
try {
|
|
const res = await fetch(`/api/v1/admin/support/${id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ retryGitea: true }),
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
// Re-fetch the single ticket's fields isn't wired up server-side, so
|
|
// reload the list to pick up the new giteaIssueUrl/giteaError.
|
|
const listRes = await fetch("/api/v1/admin/support");
|
|
if (listRes.ok) {
|
|
const data = (await listRes.json()) as Ticket[];
|
|
setTickets(data);
|
|
}
|
|
toast.success("Retried Gitea issue creation");
|
|
} catch {
|
|
toast.error("Retry failed");
|
|
} finally {
|
|
setRetrying(null);
|
|
}
|
|
}
|
|
|
|
if (tickets.length === 0) {
|
|
return <p className="text-sm text-muted-foreground">No support tickets yet.</p>;
|
|
}
|
|
|
|
return (
|
|
<div className="divide-y rounded-md border">
|
|
{tickets.map((ticket) => (
|
|
<div key={ticket.id} className="px-4 py-3 space-y-1.5">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium">{ticket.title}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{ticket.username ? `@${ticket.username}` : ticket.userEmail} · {formatDateTime(ticket.createdAt)}
|
|
</p>
|
|
</div>
|
|
<Select value={ticket.status} onValueChange={(v) => { void handleStatusChange(ticket.id, v as TicketStatus); }}>
|
|
<SelectTrigger className="w-32 shrink-0">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="open">Open</SelectItem>
|
|
<SelectItem value="triaged">Triaged</SelectItem>
|
|
<SelectItem value="closed">Closed</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{ticket.description}</p>
|
|
{ticket.attachments.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 pt-1">
|
|
{ticket.attachments.map((a) => (
|
|
<Link
|
|
key={a.id}
|
|
href={a.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="relative h-12 w-12 rounded-md border overflow-hidden block"
|
|
>
|
|
{isImage(a.contentType) ? (
|
|
<Image src={a.url} unoptimized alt="Attachment" fill className="object-cover" />
|
|
) : (
|
|
<div className="h-full w-full flex items-center justify-center bg-muted/40">
|
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2 text-xs flex-wrap pt-1">
|
|
<Badge variant="outline" className="text-xs">{ticket.type}</Badge>
|
|
<Badge variant={statusVariant[ticket.status]} className="text-xs">{ticket.status}</Badge>
|
|
{ticket.giteaIssueUrl ? (
|
|
<Link
|
|
href={ticket.giteaIssueUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-1 text-primary hover:underline"
|
|
>
|
|
View Gitea issue
|
|
<ExternalLink className="h-3 w-3" />
|
|
</Link>
|
|
) : (
|
|
<>
|
|
{ticket.giteaError && (
|
|
<span className="text-destructive" title={ticket.giteaError}>Gitea issue failed</span>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-6 px-2 text-xs"
|
|
disabled={retrying === ticket.id}
|
|
onClick={() => { void handleRetryGitea(ticket.id); }}
|
|
>
|
|
{retrying === ticket.id ? "Retrying…" : "Create Gitea issue"}
|
|
</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>
|
|
|
|
{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>
|
|
);
|
|
}
|