diff --git a/config/runtime.exs b/config/runtime.exs index 031b414b..0bbfbd00 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -873,6 +873,13 @@ if key = System.get_env("POSTHOG_API_KEY") do posthog_host: System.get_env("POSTHOG_HOST", "https://us.i.posthog.com") end +# Discord webhook for new issue reports (Engram.Notifications.Discord). +# No-op when DISCORD_WEBHOOK_URL is unset, so dev/test/self-host post +# nothing and the notifier short-circuits before any network call. +if url = System.get_env("DISCORD_WEBHOOK_URL") do + config :engram, :discord_webhook_url, url +end + # Pyroscope continuous CPU profiling. Same opt-in shape as Sentry/PostHog: # the worker's child_spec/1 returns :ignore when any of the three required # env vars is missing, so dev/test/self-host emit no profiling traffic and diff --git a/frontend/src/api/queries.ts b/frontend/src/api/queries.ts index 19c435f1..766aa9ec 100644 --- a/frontend/src/api/queries.ts +++ b/frontend/src/api/queries.ts @@ -712,6 +712,13 @@ export function useUpdateProfile() { }); } +export function useReportBug() { + return useMutation({ + mutationFn: (body: { description: string; surface: "web"; app_version: string }) => + api.post<{ report: { id: string; status: string } }>("/reports", body), + }); +} + export function useDeleteSelf() { return useMutation({ mutationFn: async ({ password }) => { diff --git a/frontend/src/settings/account-page.tsx b/frontend/src/settings/account-page.tsx index 7e65e557..84e5173f 100644 --- a/frontend/src/settings/account-page.tsx +++ b/frontend/src/settings/account-page.tsx @@ -5,6 +5,7 @@ import { DangerZoneSection } from "./account/danger-zone-section"; import { EmailSection } from "./account/email-section"; import { PasswordSection } from "./account/password-section"; import { ProfileSection } from "./account/profile-section"; +import { ReportBugSection } from "./account/report-bug-section"; import { SessionsSection } from "./account/sessions-section"; // OAuth providers enabled on this Clerk instance. Confirm against the instance @@ -27,6 +28,7 @@ export default function AccountPage() { + ); diff --git a/frontend/src/settings/account/report-bug-dialog.test.tsx b/frontend/src/settings/account/report-bug-dialog.test.tsx new file mode 100644 index 00000000..3da14924 --- /dev/null +++ b/frontend/src/settings/account/report-bug-dialog.test.tsx @@ -0,0 +1,30 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ReportBugDialog } from "./report-bug-dialog"; + +const mutate = vi.fn(); +vi.mock("@/api/queries", () => ({ + useReportBug: () => ({ mutate, isPending: false }), +})); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +describe("ReportBugDialog", () => { + beforeEach(() => mutate.mockReset()); + + it("submits the typed description with surface web", () => { + render( {}} />); + fireEvent.change(screen.getByPlaceholderText(/what happened/i), { + target: { value: "sync stalls" }, + }); + fireEvent.click(screen.getByRole("button", { name: /send report/i })); + expect(mutate).toHaveBeenCalledWith( + expect.objectContaining({ description: "sync stalls", surface: "web" }), + expect.anything(), + ); + }); + + it("disables submit when the description is empty", () => { + render( {}} />); + expect(screen.getByRole("button", { name: /send report/i })).toBeDisabled(); + }); +}); diff --git a/frontend/src/settings/account/report-bug-dialog.tsx b/frontend/src/settings/account/report-bug-dialog.tsx new file mode 100644 index 00000000..b9bf566e --- /dev/null +++ b/frontend/src/settings/account/report-bug-dialog.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import { useReportBug } from "@/api/queries"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +const inputClass = + "mt-1 block w-full rounded-md border border-input bg-card px-3 py-2 text-sm text-foreground focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring"; + +export function ReportBugDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [description, setDescription] = useState(""); + const report = useReportBug(); + + function submit() { + const text = description.trim(); + if (!text) { + return; + } + report.mutate( + { description: text, surface: "web", app_version: import.meta.env.VITE_GIT_SHA ?? "dev" }, + { + onSuccess: () => { + toast.success("Report sent"); + setDescription(""); + onOpenChange(false); + }, + onError: () => toast.error("Could not send report"), + }, + ); + } + + return ( + + + + Report a bug + + Describe what went wrong. We attach your account and a time window so we can pull the + logs. + + +
{ + e.preventDefault(); + submit(); + }} + > +