From 9f0eb59d8c05b77c504b02908fdd1fddcad939f7 Mon Sep 17 00:00:00 2001 From: dutch Date: Thu, 26 Mar 2026 23:25:49 -0400 Subject: [PATCH] feat: add structured bug report intake route --- app/api/v1/reports/bug/route.ts | 158 ++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 app/api/v1/reports/bug/route.ts diff --git a/app/api/v1/reports/bug/route.ts b/app/api/v1/reports/bug/route.ts new file mode 100644 index 000000000..e99e19618 --- /dev/null +++ b/app/api/v1/reports/bug/route.ts @@ -0,0 +1,158 @@ +/** + * POST /api/v1/reports/bug + * Receives structured bug reports from clients like Milady and forwards them + * through the existing Cloud email service. + */ + +import { randomUUID } from "node:crypto"; +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { RateLimitPresets, withRateLimit } from "@/lib/middleware/rate-limit"; +import { emailService } from "@/lib/services/email"; +import { logger } from "@/lib/utils/logger"; + +const bugReportSchema = z.object({ + source: z.string().max(120).default("unknown-client"), + submittedAt: z.string().optional(), + category: z.enum(["general", "startup-failure"]).default("general"), + description: z.string().min(1).max(500), + stepsToReproduce: z.string().min(1).max(10000), + expectedBehavior: z.string().max(10000).optional(), + actualBehavior: z.string().max(10000).optional(), + environment: z.string().max(200).optional(), + nodeVersion: z.string().max(200).optional(), + modelProvider: z.string().max(200).optional(), + appVersion: z.string().max(200).optional(), + releaseChannel: z.string().max(200).optional(), + logs: z.string().max(50000).optional(), + startup: z + .object({ + reason: z.string().max(120).optional(), + phase: z.string().max(120).optional(), + message: z.string().max(1000).optional(), + detail: z.string().max(10000).optional(), + status: z.number().int().optional(), + path: z.string().max(500).optional(), + }) + .optional(), +}); + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function section(title: string, content?: string) { + if (!content) return ""; + return ` +
+

${escapeHtml(title)}

+
${escapeHtml(content)}
+
`; +} + +async function handlePOST(request: NextRequest) { + const body = await request.json(); + const validated = bugReportSchema.parse(body); + + const reportId = `rpt_${randomUUID()}`; + const receivedAt = new Date().toISOString(); + const recipient = + process.env.BUG_REPORT_EMAIL_TO ?? + process.env.SUPPORT_EMAIL ?? + "developer@elizalabs.ai"; + + const startupSummary = validated.startup + ? JSON.stringify(validated.startup, null, 2) + : undefined; + + const html = ` + + + + + New Structured Bug Report + + +
+

Structured Bug Report

+
+ + + + + + + + +
Report ID${escapeHtml(reportId)}
Source${escapeHtml(validated.source)}
Category${escapeHtml(validated.category)}
Received${escapeHtml(receivedAt)}
App Version${escapeHtml(validated.appVersion || "unknown")}
Release Channel${escapeHtml(validated.releaseChannel || "unknown")}
Environment${escapeHtml(validated.environment || "unknown")}
+
+ ${section("Description", validated.description)} + ${section("Steps to Reproduce", validated.stepsToReproduce)} + ${section("Expected Behavior", validated.expectedBehavior)} + ${section("Actual Behavior", validated.actualBehavior)} + ${section("Startup Context", startupSummary)} + ${section("Logs", validated.logs)} +
+ +`; + + const text = [ + "Structured Bug Report", + `Report ID: ${reportId}`, + `Source: ${validated.source}`, + `Category: ${validated.category}`, + `Received: ${receivedAt}`, + `App Version: ${validated.appVersion ?? "unknown"}`, + `Release Channel: ${validated.releaseChannel ?? "unknown"}`, + `Environment: ${validated.environment ?? "unknown"}`, + "", + "Description:", + validated.description, + "", + "Steps to Reproduce:", + validated.stepsToReproduce, + validated.expectedBehavior ? `\nExpected Behavior:\n${validated.expectedBehavior}` : "", + validated.actualBehavior ? `\nActual Behavior:\n${validated.actualBehavior}` : "", + startupSummary ? `\nStartup Context:\n${startupSummary}` : "", + validated.logs ? `\nLogs:\n${validated.logs}` : "", + ] + .filter(Boolean) + .join("\n"); + + const sent = await emailService.send({ + to: recipient, + subject: `[Bug Report] ${validated.source} - ${validated.description.slice(0, 100)}`, + html, + text, + }); + + if (!sent) { + logger.error("[BugReport] Failed to send bug report email", { + reportId, + source: validated.source, + category: validated.category, + }); + return NextResponse.json( + { accepted: false, error: "Email service unavailable" }, + { status: 503 }, + ); + } + + logger.info("[BugReport] Structured bug report accepted", { + reportId, + source: validated.source, + category: validated.category, + }); + + return NextResponse.json({ + accepted: true, + id: reportId, + }); +} + +export const POST = withRateLimit(handlePOST, RateLimitPresets.STRICT);