Repository navigation
feat(web): add batched feedback submission over tRPC and HTTP with a per-minute limit #6864
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import 'server-only'; | ||
|
|
||
| import { NextResponse, type NextRequest } from 'next/server'; | ||
| import * as z from 'zod'; | ||
| import { FeedbackFor, FeedbackSource } from '@/lib/feedback/enums'; | ||
| import { isFeedbackRateLimitError, submitUserFeedback } from '@/lib/feedback/submit'; | ||
| import { getUserFromAuth } from '@/lib/user/server'; | ||
|
|
||
| /** | ||
| * HTTP twin of the `feedback.submit` tRPC mutation. An authenticated agent | ||
| * posts feedback or a bug report here; the submission lands in the same | ||
| * `user_feedback` store and shares the same per-user limit. Unauthenticated | ||
| * callers get 401, a caller inside the rolling window gets 429 with the | ||
| * batch-into-one-submission message. | ||
| */ | ||
| const SubmitFeedbackRequestSchema = z.object({ | ||
| feedback_text: z.string().optional().default(''), | ||
| feedback_for: z.string().min(1).default(FeedbackFor.Unknown), | ||
| feedback_batch: z.string().min(1).optional(), | ||
| source: z.string().min(1).default(FeedbackSource.Web), | ||
| context_json: z.record(z.string(), z.unknown()).default({}), | ||
| }); | ||
|
|
||
| export async function POST(request: NextRequest): Promise<NextResponse> { | ||
| const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); | ||
| if (!user) return authFailedResponse; | ||
|
|
||
| let rawBody: unknown; | ||
| try { | ||
| rawBody = await request.json(); | ||
| } catch { | ||
| return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); | ||
| } | ||
|
|
||
| const parsed = SubmitFeedbackRequestSchema.safeParse(rawBody); | ||
| if (!parsed.success) { | ||
| return NextResponse.json({ error: 'Invalid feedback submission' }, { status: 400 }); | ||
| } | ||
|
|
||
| try { | ||
| const { id } = await submitUserFeedback({ | ||
| userId: user.id, | ||
| feedbackText: parsed.data.feedback_text, | ||
| feedbackFor: parsed.data.feedback_for, | ||
| feedbackBatch: parsed.data.feedback_batch, | ||
| source: parsed.data.source, | ||
| contextJson: parsed.data.context_json, | ||
| }); | ||
| return NextResponse.json({ id }, { status: 200 }); | ||
| } catch (error) { | ||
| if (isFeedbackRateLimitError(error)) { | ||
| return NextResponse.json( | ||
| { error: error.message }, | ||
| { | ||
| status: 429, | ||
| headers: { 'Retry-After': String(Math.ceil(error.retryAfterMs / 1000)) }, | ||
| } | ||
| ); | ||
| } | ||
| throw error; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| import 'server-only'; | ||
|
|
||
| import { desc, eq } from 'drizzle-orm'; | ||
| import { db } from '@/lib/drizzle'; | ||
| import { user_feedback } from '@kilocode/db/schema'; | ||
| import { FeedbackFor, FeedbackSource } from '@/lib/feedback/enums'; | ||
| import { SLACK_USER_FEEDBACK_WEBHOOK_URL } from '@/lib/config.server'; | ||
|
|
||
| /** | ||
| * Shared feedback submission service. | ||
| * | ||
| * Both the `userFeedback.create` tRPC mutation (published in the MCP catalog and | ||
| * reachable via `kilo_call`) and the HTTP `/feedback` endpoint funnel through | ||
| * here so an authenticated agent gets the same storage and the same limit. | ||
| * | ||
| * Authentication is the caller's responsibility: it resolves the authenticated | ||
| * user and passes the user id in. This module never reads auth headers itself. | ||
| * | ||
| * Storage: submissions are written to the existing `user_feedback` PostgreSQL | ||
| * table in `apps/web`. `apps/web` has no Cloudflare D1 or R2 binding, so the | ||
| * "d2" store maps to this table, which is where the repo already keeps | ||
| * user feedback. | ||
| */ | ||
|
|
||
| /** Rolling window in which a single user may submit at most one piece of feedback. */ | ||
| export const FEEDBACK_RATE_LIMIT_WINDOW_MS = 60_000; | ||
|
|
||
| /** | ||
| * Message returned to a limited caller. It states the limit and asks the caller | ||
| * to batch, so an agent can self-correct instead of retrying blindly. | ||
| */ | ||
| export const FEEDBACK_RATE_LIMIT_MESSAGE = | ||
| 'Feedback is limited to 1 submission per minute per user. Please batch your feedback into one submission and try again.'; | ||
|
|
||
| /** | ||
| * Thrown when a user already submitted feedback within the rolling window. | ||
| * Callers map this to their transport error shape (tRPC `TOO_MANY_REQUESTS`, | ||
| * HTTP 429) while preserving {@link FeedbackRateLimitError.message}. | ||
| */ | ||
| export class FeedbackRateLimitError extends Error { | ||
| /** Milliseconds until the next submission is allowed. */ | ||
| readonly retryAfterMs: number; | ||
|
|
||
| constructor(retryAfterMs: number) { | ||
| super(FEEDBACK_RATE_LIMIT_MESSAGE); | ||
| this.name = 'FeedbackRateLimitError'; | ||
| this.retryAfterMs = retryAfterMs; | ||
| } | ||
| } | ||
|
|
||
| export function isFeedbackRateLimitError(error: unknown): error is FeedbackRateLimitError { | ||
| return error instanceof FeedbackRateLimitError; | ||
| } | ||
|
|
||
| export type SubmitUserFeedbackInput = { | ||
| /** Resolved authenticated user id. */ | ||
| userId: string; | ||
| feedbackText?: string; | ||
| feedbackFor?: string; | ||
| feedbackBatch?: string; | ||
| source?: string; | ||
| contextJson?: Record<string, unknown>; | ||
| }; | ||
|
|
||
| export type SubmitUserFeedbackResult = { | ||
| id: string; | ||
| }; | ||
|
|
||
| /** | ||
| * Parse the PostgreSQL `timestamptz` text Drizzle returns with `mode: 'string'` | ||
| * (for example `2026-04-29 01:16:12.945+00`). Returns null for unparseable text | ||
| * so a malformed legacy row cannot block all future submissions. | ||
| */ | ||
| function parseCreatedAt(value: string): number | null { | ||
| const parsed = new Date(value).getTime(); | ||
| return Number.isNaN(parsed) ? null : parsed; | ||
| } | ||
|
|
||
| /** | ||
| * Enforce the per-user rolling limit by reading the user's most recent | ||
| * `user_feedback` row. `IDX_user_feedback_kilo_user_id` backs the lookup and | ||
| * `IDX_user_feedback_created_at` backs the ordering. | ||
| */ | ||
| async function enforceFeedbackRateLimit(userId: string, now: number): Promise<void> { | ||
| const [mostRecent] = await db | ||
| .select({ created_at: user_feedback.created_at }) | ||
| .from(user_feedback) | ||
| .where(eq(user_feedback.kilo_user_id, userId)) | ||
| .orderBy(desc(user_feedback.created_at)) | ||
| .limit(1); | ||
|
|
||
| if (!mostRecent) return; | ||
|
|
||
| const submittedAt = parseCreatedAt(mostRecent.created_at); | ||
| if (submittedAt === null) return; | ||
|
|
||
| const elapsedMs = now - submittedAt; | ||
| if (elapsedMs < FEEDBACK_RATE_LIMIT_WINDOW_MS) { | ||
| throw new FeedbackRateLimitError(FEEDBACK_RATE_LIMIT_WINDOW_MS - Math.max(elapsedMs, 0)); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Best-effort notification to the Kilo Slack workspace. Uses an Incoming Webhook | ||
| * URL so it is not coupled to any user/org Slack installation, and never blocks | ||
| * or fails the submission. | ||
| */ | ||
| function notifySlack(input: SubmitUserFeedbackInput): void { | ||
| if (!SLACK_USER_FEEDBACK_WEBHOOK_URL) return; | ||
|
|
||
| const textLines = [ | ||
| '*New user feedback:* :old_man_yells_at_kilo:', | ||
| `• user: \`${input.userId}\``, | ||
| `• for: \`${input.feedbackFor ?? FeedbackFor.Unknown}\``, | ||
| `• source: \`${input.source ?? FeedbackSource.Unknown}\``, | ||
| input.feedbackBatch ? `• batch: \`${input.feedbackBatch}\`` : null, | ||
| '', | ||
| '• raw feedback:', | ||
| '```', | ||
| input.feedbackText?.trim() ? input.feedbackText.trim() : '_<empty>_', | ||
| '```', | ||
| ].filter((line): line is string => !!line); | ||
|
|
||
| fetch(SLACK_USER_FEEDBACK_WEBHOOK_URL, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ text: textLines.join('\n') }), | ||
| }).catch(error => { | ||
| console.error('[UserFeedback] Failed to post to Slack webhook', error); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Persist one feedback submission for an authenticated user. | ||
| * | ||
| * Throws {@link FeedbackRateLimitError} when the user submitted feedback within | ||
| * the last {@link FEEDBACK_RATE_LIMIT_WINDOW_MS}. | ||
| */ | ||
| export async function submitUserFeedback( | ||
| input: SubmitUserFeedbackInput | ||
| ): Promise<SubmitUserFeedbackResult> { | ||
| const now = Date.now(); | ||
| await enforceFeedbackRateLimit(input.userId, now); | ||
|
|
||
| const [inserted] = await db | ||
| .insert(user_feedback) | ||
| .values({ | ||
| kilo_user_id: input.userId, | ||
| feedback_text: input.feedbackText ?? '', | ||
| feedback_for: input.feedbackFor ?? FeedbackFor.Unknown, | ||
| feedback_batch: input.feedbackBatch, | ||
| source: input.source ?? FeedbackSource.Unknown, | ||
| context_json: input.contextJson ?? {}, | ||
| }) | ||
| .returning({ id: user_feedback.id }); | ||
|
|
||
| notifySlack(input); | ||
|
|
||
| return { id: inserted.id }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import 'server-only'; | ||
|
|
||
| import { TRPCError } from '@trpc/server'; | ||
| import * as z from 'zod'; | ||
| import { baseProcedure, createTRPCRouter } from '@/lib/trpc/init'; | ||
| import { FeedbackFor, FeedbackSource } from '@/lib/feedback/enums'; | ||
| import { isFeedbackRateLimitError, submitUserFeedback } from '@/lib/feedback/submit'; | ||
|
|
||
| /** | ||
| * MCP-facing feedback entry point. `feedback.submit` is the tRPC twin of the | ||
| * HTTP `POST /feedback` route: both authenticate, both funnel into the shared | ||
| * submission service, and both surface the same rate-limit message. An | ||
| * unauthenticated tRPC caller is refused by `createTRPCContext`, which throws | ||
| * `UNAUTHORIZED` before this procedure runs. | ||
| */ | ||
| const SubmitFeedbackInputSchema = z.object({ | ||
| feedback_text: z.string().optional().default(''), | ||
| feedback_for: z.string().min(1).default(FeedbackFor.Unknown), | ||
| feedback_batch: z.string().min(1).optional(), | ||
| source: z.string().min(1).default(FeedbackSource.Web), | ||
| context_json: z.record(z.string(), z.unknown()).default({}), | ||
| }); | ||
|
|
||
| export const feedbackRouter = createTRPCRouter({ | ||
| submit: baseProcedure.input(SubmitFeedbackInputSchema).mutation(async ({ ctx, input }) => { | ||
| try { | ||
| return await submitUserFeedback({ | ||
| userId: ctx.user.id, | ||
| feedbackText: input.feedback_text, | ||
| feedbackFor: input.feedback_for, | ||
| feedbackBatch: input.feedback_batch, | ||
| source: input.source, | ||
| contextJson: input.context_json, | ||
| }); | ||
| } catch (error) { | ||
| if (isFeedbackRateLimitError(error)) { | ||
| throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: error.message }); | ||
| } | ||
| throw error; | ||
| } | ||
| }), | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.