Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions docs/public/public-api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<ticket UUID>", "status": "open",
"reference": "raft-android · 1.0.4 (1000400) · ticket <ticket UUID>",
"ticket_url": "https://app.hands.build/apps/<appId>/feedback/<ticket UUID>" }`.
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": "<ticket UUID>",
"status": "open",
"attachment_names": ["diagnostics.zip"],
"attachment_refs": [
{ "id": "<attachment UUID>", "filename": "diagnostics.zip" }
],
"reference": "raft-android · 1.0.4 (1000400) · ticket <ticket UUID>\nattachments:\n<attachment UUID> · diagnostics.zip",
"ticket_url": "https://app.hands.build/apps/<appId>/feedback/<ticket UUID>"
}
```

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` /
Expand Down
4 changes: 4 additions & 0 deletions worker/src/openapi/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
68 changes: 41 additions & 27 deletions worker/src/routes/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -1012,35 +1032,29 @@ 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<FeedbackAttachmentRef>();
const attachmentRefs = attachments.results.filter(({ id, filename }) => id && filename);
const attachmentNames = attachmentRefs.map(({ filename }) => filename);

return c.json(
{
id: ticketId,
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,
},
Expand Down
12 changes: 9 additions & 3 deletions worker/test/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9567,6 +9567,7 @@ describe("quiver public API v2 — scope resolution", () => {
const replayBody = await responseJson<any>(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);

Expand Down Expand Up @@ -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}`,
Expand Down
Loading