diff --git a/docs/public/public-api-reference.md b/docs/public/public-api-reference.md index 30d7d571..d49bd4a1 100644 --- a/docs/public/public-api-reference.md +++ b/docs/public/public-api-reference.md @@ -302,10 +302,24 @@ find and rotate the key in the app's Settings tab or via Returns `201` for a new ticket. An exact `submission_id` replay returns `200` with the same ticket and `idempotent_replay: true`. Both responses include the -full ticket UUID in `id`, plus a copyable `reference` and `ticket_url`, for -example `{ "id": "", "status": "open", -"reference": "raft-android · 1.0.4 (1000400) · ticket ", -"ticket_url": "https://app.hands.build/apps//feedback/" }`. +full ticket UUID in `id`, plus a copyable `reference` and `ticket_url`. +`attachment_refs` exposes each attachment's stable Hands UUID and filename; +the legacy `attachment_names` list remains available. New submissions and +idempotent replays return the same attachment references, for example: + +```json +{ + "id": "", + "status": "open", + "attachment_names": ["diagnostics.zip"], + "attachment_refs": [ + { "id": "", "filename": "diagnostics.zip" } + ], + "reference": "raft-android · 1.0.4 (1000400) · ticket \nattachments:\n · diagnostics.zip", + "ticket_url": "https://app.hands.build/apps//feedback/" +} +``` + Rate limit: 10 submissions per hour per app + client IP. Tickets appear in the admin Feedback tab; a `feedback:new` webhook fires for subscribed endpoints (crash tickets can additionally trigger `crash:new_group` / diff --git a/worker/src/openapi/public.ts b/worker/src/openapi/public.ts index a58bec52..376a4111 100644 --- a/worker/src/openapi/public.ts +++ b/worker/src/openapi/public.ts @@ -139,6 +139,10 @@ const FeedbackSubmitResponse = z status: z.string(), attachments: z.number().int().optional(), attachment_names: z.array(z.string()).optional(), + attachment_refs: z.array(z.object({ + id: z.string().uuid(), + filename: z.string(), + })).optional(), reference: z.string().optional(), ticket_url: z.string().nullable().optional(), idempotent_replay: z.boolean().optional(), diff --git a/worker/src/routes/feedback.ts b/worker/src/routes/feedback.ts index 92bdc1dd..160c6760 100644 --- a/worker/src/routes/feedback.ts +++ b/worker/src/routes/feedback.ts @@ -936,19 +936,22 @@ export async function handlePublicFeedbackSubmit(c: Context<{ Bindings: Env }>) status: "open", versionName: meta("version_name"), versionCode, - attachmentNames: attachmentRows.map((attachment) => attachment.filename), + attachmentRefs: attachmentRows.map(({ id, filename }) => ({ id, filename })), }); } -function feedbackNewSubmitResponse( - c: Context<{ Bindings: Env }>, +type FeedbackAttachmentRef = { + id: string; + filename: string; +}; + +function feedbackReference( app: FeedbackApp, input: { ticketId: string; - status: string; versionName: string | null; versionCode: number | null; - attachmentNames: string[]; + attachmentRefs: FeedbackAttachmentRef[]; }, ) { const versionLabel = input.versionName @@ -961,15 +964,32 @@ function feedbackNewSubmitResponse( const referenceLine = [app.slug, versionLabel, `ticket ${input.ticketId}`] .filter(Boolean) .join(" · "); - const reference = input.attachmentNames.length - ? `${referenceLine}\nattachments:\n${input.attachmentNames.join("\n")}` + return input.attachmentRefs.length + ? `${referenceLine}\nattachments:\n${input.attachmentRefs + .map(({ id, filename }) => `${id} · ${filename}`) + .join("\n")}` : referenceLine; +} + +function feedbackNewSubmitResponse( + c: Context<{ Bindings: Env }>, + app: FeedbackApp, + input: { + ticketId: string; + status: string; + versionName: string | null; + versionCode: number | null; + attachmentRefs: FeedbackAttachmentRef[]; + }, +) { + const attachmentNames = input.attachmentRefs.map(({ filename }) => filename); return c.json({ id: input.ticketId, status: input.status, - attachments: input.attachmentNames.length, - attachment_names: input.attachmentNames, - reference, + attachments: input.attachmentRefs.length, + attachment_names: attachmentNames, + attachment_refs: input.attachmentRefs, + reference: feedbackReference(app, input), ticket_url: `${dashboardOrigin(c.env)}/apps/${app.id}/feedback/${input.ticketId}`, idempotent_replay: false, }, 201); @@ -1012,27 +1032,15 @@ async function feedbackSubmitResponse( if (!ticket) return c.json({ error: "feedback ticket not found" }, 500); const attachments = await c.env.DB.prepare( - `SELECT filename + `SELECT id, filename FROM feedback_attachments WHERE ticket_id = ?1 ORDER BY created_at, id`, ) .bind(ticketId) - .all<{ filename: string }>(); - const attachmentNames = attachments.results.map((attachment) => attachment.filename).filter(Boolean); - const versionLabel = ticket.version_name - ? ticket.version_code != null - ? `${ticket.version_name} (${ticket.version_code})` - : ticket.version_name - : ticket.version_code != null - ? String(ticket.version_code) - : null; - const referenceLine = [app.slug, versionLabel, `ticket ${ticketId}`] - .filter(Boolean) - .join(" · "); - const reference = attachmentNames.length - ? `${referenceLine}\nattachments:\n${attachmentNames.join("\n")}` - : referenceLine; + .all(); + const attachmentRefs = attachments.results.filter(({ id, filename }) => id && filename); + const attachmentNames = attachmentRefs.map(({ filename }) => filename); return c.json( { @@ -1040,7 +1048,13 @@ async function feedbackSubmitResponse( status: ticket.status, attachments: attachmentNames.length, attachment_names: attachmentNames, - reference, + attachment_refs: attachmentRefs, + reference: feedbackReference(app, { + ticketId, + versionName: ticket.version_name, + versionCode: ticket.version_code, + attachmentRefs, + }), ticket_url: `${dashboardOrigin(c.env)}/apps/${app.id}/feedback/${ticketId}`, idempotent_replay: idempotentReplay, }, diff --git a/worker/test/routes.test.ts b/worker/test/routes.test.ts index 93c2877f..8ebb0ebb 100644 --- a/worker/test/routes.test.ts +++ b/worker/test/routes.test.ts @@ -9567,6 +9567,7 @@ describe("quiver public API v2 — scope resolution", () => { const replayBody = await responseJson(replay); expect(replayBody.id).toBe(firstBody.id); expect(replayBody.reference).toBe(firstBody.reference); + expect(replayBody.attachment_refs).toEqual(firstBody.attachment_refs); expect(replayBody.idempotent_replay).toBe(true); expect(putCalls).toHaveLength(1); @@ -10137,9 +10138,14 @@ describe("quiver public API v2 — scope resolution", () => { expect(submittedBody.attachments).toBe(1); expect(submittedBody.id).toMatch(/^[0-9a-f-]{36}$/); expect(submittedBody.reference).toContain(`ticket ${submittedBody.id}`); - // The copyable reference lists attachment filenames, one per line, so a - // reading agent knows the ticket carries files and will fetch them. - expect(submittedBody.reference).toContain("attachments:\nlogcat.txt"); + expect(submittedBody.attachment_refs).toEqual([ + { id: expect.stringMatching(/^[0-9a-f-]{36}$/), filename: "logcat.txt" }, + ]); + // Keep the copyable reference actionable without a second ticket-detail + // lookup: every attachment line carries its stable Hands UUID and name. + expect(submittedBody.reference).toContain( + `attachments:\n${submittedBody.attachment_refs[0].id} · logcat.txt`, + ); expect(submittedBody.attachment_names).toEqual(["logcat.txt"]); expect(submittedBody.ticket_url).toBe( `https://dashboard.example/apps/app-scope/feedback/${submittedBody.id}`,