diff --git a/.env.example b/.env.example index c578d1f..c8ea439 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,11 @@ SMTP_FROM=Epicure NEXT_PUBLIC_VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= +# Gitea (optional — in-app support tickets open an issue here if all three are set) +GITEA_URL= +GITEA_TOKEN= +GITEA_REPO=owner/repo + # Stripe (optional — webhook stub only) STRIPE_WEBHOOK_SECRET= diff --git a/CHANGELOG.md b/CHANGELOG.md index f01686b..2c0a185 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.49.0 — 2026-07-18 12:30 + +### Added +- New Support page — report a bug, share a suggestion, or ask a question right from the app. Submissions email you a confirmation and (if an admin has configured a Gitea repo in Settings) automatically open a tracked issue. Admins get a Support section in the admin panel to triage tickets and retry issue creation. + ## 0.48.2 — 2026-07-18 11:15 ### Fixed diff --git a/apps/web/app/(app)/support/page.tsx b/apps/web/app/(app)/support/page.tsx new file mode 100644 index 0000000..bd767b2 --- /dev/null +++ b/apps/web/app/(app)/support/page.tsx @@ -0,0 +1,40 @@ +import type { Metadata } from "next"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth/server"; +import { db, supportTickets, eq, desc } from "@epicure/db"; +import { SupportManager } from "@/components/support/support-manager"; +import { getMessages } from "@/lib/i18n/server"; + +export const metadata: Metadata = {}; + +export default async function SupportPage() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return null; + const m = getMessages((session.user as { locale?: string }).locale); + + const rows = await db + .select() + .from(supportTickets) + .where(eq(supportTickets.userId, session.user.id)) + .orderBy(desc(supportTickets.createdAt)); + + return ( +
+
+

{m.support.title}

+

{m.support.subtitle}

+
+ ({ + id: r.id, + type: r.type, + title: r.title, + description: r.description, + status: r.status, + giteaIssueUrl: r.giteaIssueUrl, + createdAt: r.createdAt.toISOString(), + }))} + /> +
+ ); +} diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx index 319a3f0..36a4eb9 100644 --- a/apps/web/app/admin/layout.tsx +++ b/apps/web/app/admin/layout.tsx @@ -3,7 +3,7 @@ import { headers } from "next/headers"; import { auth } from "@/lib/auth/server"; import { db, users, eq } from "@epicure/db"; import Link from "next/link"; -import { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft, Gauge, Mail, Flag, History } from "lucide-react"; +import { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft, Gauge, Mail, Flag, History, LifeBuoy } from "lucide-react"; import { cn } from "@/lib/utils"; const adminNav = [ @@ -12,6 +12,7 @@ const adminNav = [ { href: "/admin/invites", label: "Invites", icon: Mail }, { href: "/admin/recipes", label: "Recipes", icon: BookOpen }, { href: "/admin/reports", label: "Reports", icon: Flag }, + { href: "/admin/support", label: "Support", icon: LifeBuoy }, { href: "/admin/tiers", label: "Tier Limits", icon: Gauge }, { href: "/admin/audit-logs", label: "Audit Logs", icon: ClipboardList }, { href: "/admin/storage", label: "Storage", icon: HardDrive }, diff --git a/apps/web/app/admin/settings/page.tsx b/apps/web/app/admin/settings/page.tsx index 50621f4..291b0c1 100644 --- a/apps/web/app/admin/settings/page.tsx +++ b/apps/web/app/admin/settings/page.tsx @@ -11,6 +11,11 @@ const SETTING_GROUPS = [ description: "Keys for web push notifications. Generate with: npx web-push generate-vapid-keys", keys: ["NEXT_PUBLIC_VAPID_PUBLIC_KEY", "VAPID_PRIVATE_KEY"] as const, }, + { + title: "Gitea Integration", + description: "Support tickets submitted in-app automatically open an issue on this Gitea repo. Token needs issue read/write scope on the target repo.", + keys: ["GITEA_URL", "GITEA_TOKEN", "GITEA_REPO"] as const, + }, ]; export default async function AdminSettingsPage() { diff --git a/apps/web/app/admin/support/page.tsx b/apps/web/app/admin/support/page.tsx new file mode 100644 index 0000000..62489df --- /dev/null +++ b/apps/web/app/admin/support/page.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from "next"; +import { db, users, supportTickets, eq, desc } from "@epicure/db"; +import { AdminSupportManager } from "@/components/admin/admin-support-manager"; + +export const metadata: Metadata = {}; + +export default async function AdminSupportPage() { + const rows = await db + .select({ + id: supportTickets.id, + userEmail: users.email, + username: users.username, + type: supportTickets.type, + title: supportTickets.title, + description: supportTickets.description, + status: supportTickets.status, + giteaIssueUrl: supportTickets.giteaIssueUrl, + giteaError: supportTickets.giteaError, + createdAt: supportTickets.createdAt, + }) + .from(supportTickets) + .innerJoin(users, eq(supportTickets.userId, users.id)) + .orderBy(desc(supportTickets.createdAt)); + + return ( +
+
+

Support Tickets

+

+ Bug reports, suggestions, and questions submitted through the app. +

+
+ ({ ...r, createdAt: r.createdAt.toISOString() }))} + /> +
+ ); +} diff --git a/apps/web/app/api/v1/admin/settings/route.ts b/apps/web/app/api/v1/admin/settings/route.ts index 13995e0..f54597e 100644 --- a/apps/web/app/api/v1/admin/settings/route.ts +++ b/apps/web/app/api/v1/admin/settings/route.ts @@ -20,6 +20,9 @@ const ALLOWED_KEYS: SiteSettingKey[] = [ "DEFAULT_VISION_MODEL", "DEFAULT_MEAL_PLAN_PROVIDER", "DEFAULT_MEAL_PLAN_MODEL", + "GITEA_URL", + "GITEA_TOKEN", + "GITEA_REPO", ]; async function requireAdmin() { diff --git a/apps/web/app/api/v1/admin/support/[id]/route.ts b/apps/web/app/api/v1/admin/support/[id]/route.ts new file mode 100644 index 0000000..2d6473f --- /dev/null +++ b/apps/web/app/api/v1/admin/support/[id]/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, supportTickets, eq } from "@epicure/db"; +import { requireAdmin } from "@/lib/api-auth"; +import { createGiteaIssue } from "@/lib/gitea"; + +const UpdateTicketBody = z.object({ + status: z.enum(["open", "triaged", "closed"]).optional(), + retryGitea: z.boolean().optional(), +}); + +export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { response } = await requireAdmin(); + if (response) return response; + + const { id } = await params; + const body = (await req.json()) as unknown; + const parsed = UpdateTicketBody.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 }); + } + + const [ticket] = await db.select().from(supportTickets).where(eq(supportTickets.id, id)); + if (!ticket) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const updates: Partial = { updatedAt: new Date() }; + + if (parsed.data.status) { + updates.status = parsed.data.status; + } + + if (parsed.data.retryGitea) { + const { url, error } = await createGiteaIssue({ + type: ticket.type, + title: ticket.title, + body: `${ticket.description}\n\n---\nReported via Epicure support form by user \`${ticket.userId}\`.`, + }); + updates.giteaIssueUrl = url; + updates.giteaError = error; + } + + await db.update(supportTickets).set(updates).where(eq(supportTickets.id, id)); + + return NextResponse.json({ ok: true }); +} diff --git a/apps/web/app/api/v1/admin/support/route.ts b/apps/web/app/api/v1/admin/support/route.ts new file mode 100644 index 0000000..324b6d4 --- /dev/null +++ b/apps/web/app/api/v1/admin/support/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { db, users, supportTickets, eq, desc } from "@epicure/db"; +import { requireAdmin } from "@/lib/api-auth"; + +export async function GET() { + const { response } = await requireAdmin(); + if (response) return response; + + const rows = await db + .select({ + id: supportTickets.id, + userId: supportTickets.userId, + userEmail: users.email, + username: users.username, + type: supportTickets.type, + title: supportTickets.title, + description: supportTickets.description, + status: supportTickets.status, + giteaIssueUrl: supportTickets.giteaIssueUrl, + giteaError: supportTickets.giteaError, + createdAt: supportTickets.createdAt, + updatedAt: supportTickets.updatedAt, + }) + .from(supportTickets) + .innerJoin(users, eq(supportTickets.userId, users.id)) + .orderBy(desc(supportTickets.createdAt)); + + return NextResponse.json(rows); +} diff --git a/apps/web/app/api/v1/support/route.ts b/apps/web/app/api/v1/support/route.ts new file mode 100644 index 0000000..0c00452 --- /dev/null +++ b/apps/web/app/api/v1/support/route.ts @@ -0,0 +1,93 @@ +import { NextRequest, NextResponse } from "next/server"; +import crypto from "node:crypto"; +import { z } from "zod"; +import { db, supportTickets, eq, desc } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; +import { sendEmail, supportTicketReceivedHtml } from "@/lib/email"; +import { createGiteaIssue } from "@/lib/gitea"; + +const CreateTicketBody = z.object({ + type: z.enum(["bug", "suggestion", "question"]), + title: z.string().trim().min(3).max(200), + description: z.string().trim().min(10).max(5000), +}); + +export async function GET() { + const { session, response } = await requireSession(); + if (response) return response; + + const rows = await db + .select() + .from(supportTickets) + .where(eq(supportTickets.userId, session!.user.id)) + .orderBy(desc(supportTickets.createdAt)); + + return NextResponse.json(rows); +} + +export async function POST(req: NextRequest) { + const { session, response } = await requireSession(); + if (response) return response; + + const body = (await req.json()) as unknown; + const parsed = CreateTicketBody.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Validation error", issues: parsed.error.issues }, + { status: 400 } + ); + } + + const { type, title, description } = parsed.data; + const id = crypto.randomUUID(); + const now = new Date(); + + const { url: giteaIssueUrl, error: giteaError } = await createGiteaIssue({ + type, + title, + body: `${description}\n\n---\nReported via Epicure support form by user \`${session!.user.id}\`.`, + }); + + await db.insert(supportTickets).values({ + id, + userId: session!.user.id, + type, + title, + description, + status: "open", + giteaIssueUrl, + giteaError, + createdAt: now, + updatedAt: now, + }); + + const baseUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"; + const ticketUrl = `${baseUrl}/support`; + if (session!.user.email) { + try { + await sendEmail({ + to: session!.user.email, + subject: "We got your message — Epicure", + html: supportTicketReceivedHtml({ type, title, ticketUrl, giteaIssueUrl }), + }); + } catch { + // Ticket is saved either way — email failure shouldn't fail the request. + } + } + + return NextResponse.json( + { + id, + userId: session!.user.id, + type, + title, + description, + status: "open" as const, + giteaIssueUrl, + giteaError, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }, + { status: 201 } + ); +} diff --git a/apps/web/components/admin/admin-support-manager.tsx b/apps/web/components/admin/admin-support-manager.tsx new file mode 100644 index 0000000..1b9a84e --- /dev/null +++ b/apps/web/components/admin/admin-support-manager.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { toast } from "sonner"; +import { ExternalLink } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +type TicketStatus = "open" | "triaged" | "closed"; + +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; +}; + +function formatDateTime(iso: string) { + return new Date(iso).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +const statusVariant: Record = { + open: "default", + triaged: "secondary", + closed: "outline", +}; + +export function AdminSupportManager({ initialTickets }: { initialTickets: Ticket[] }) { + const [tickets, setTickets] = useState(initialTickets); + const [retrying, setRetrying] = useState(null); + + 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

No support tickets yet.

; + } + + return ( +
+ {tickets.map((ticket) => ( +
+
+
+

{ticket.title}

+

+ {ticket.username ? `@${ticket.username}` : ticket.userEmail} · {formatDateTime(ticket.createdAt)} +

+
+ +
+

{ticket.description}

+
+ {ticket.type} + {ticket.status} + {ticket.giteaIssueUrl ? ( + + View Gitea issue + + + ) : ( + <> + {ticket.giteaError && ( + Gitea issue failed + )} + + + )} +
+
+ ))} +
+ ); +} diff --git a/apps/web/components/layout/nav.tsx b/apps/web/components/layout/nav.tsx index e920101..f67e3f0 100644 --- a/apps/web/components/layout/nav.tsx +++ b/apps/web/components/layout/nav.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useTheme } from "next-themes"; -import { BookOpen, Calendar, Package, ChefHat, User, FolderOpen, ShoppingCart, Shield, Search, Compass, Menu, Sun, Moon, Monitor, Apple } from "lucide-react"; +import { BookOpen, Calendar, Package, ChefHat, User, FolderOpen, ShoppingCart, Shield, Search, Compass, Menu, Sun, Moon, Monitor, Apple, LifeBuoy } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button, buttonVariants } from "@/components/ui/button"; import { @@ -130,6 +130,12 @@ export function Nav() { {t("settings")} + + + + {t("support")} + +
(initialTickets); + const [type, setType] = useState("bug"); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (title.trim().length < 3 || description.trim().length < 10) return; + setSubmitting(true); + try { + const res = await fetch("/api/v1/support", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type, title: title.trim(), description: description.trim() }), + }); + if (!res.ok) { + const data = (await res.json()) as { error?: string }; + throw new Error(data.error ?? t("submitFailed")); + } + const ticket = (await res.json()) as Ticket; + setTickets((prev) => [ticket, ...prev]); + setTitle(""); + setDescription(""); + setType("bug"); + toast.success(t("submitSuccess")); + } catch (err) { + toast.error(err instanceof Error ? err.message : t("submitFailed")); + } finally { + setSubmitting(false); + } + } + + const statusVariant: Record = { + open: "default", + triaged: "secondary", + closed: "outline", + }; + + return ( +
+
+
+ + +
+ +
+ + setTitle(e.target.value)} + placeholder={t("titlePlaceholder")} + maxLength={200} + required + /> +
+ +
+ +