Files
SnapOtter/apps/api/src/routes/feedback.ts
T
SnapOtterandGitHub ca076f91fd fix: critical first-login soft-lock in usage survey overlay (#392)
* fix: prevent UsageSurveyOverlay from soft-locking the first-login password-change flow

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp

* fix: prevent double feedback submission when the settings write fails

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp

* refactor: consolidate feedback enums into packages/shared as a single source of truth

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp

* feat: add ARIA semantics, dismiss-button guard, and shared auth-route list to UsageSurveyOverlay

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp

* test: cover the submit-failure retry path and a persona-only minimal payload

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp
2026-07-02 18:37:12 +08:00

139 lines
4.5 KiB
TypeScript

import {
FEEDBACK_ERROR_CATEGORY_VALUES,
FEEDBACK_FRICTION_AREA_VALUES,
FEEDBACK_IMPORTANT_AREA_VALUES,
FEEDBACK_INSTALL_METHOD_VALUES,
FEEDBACK_SENTIMENT_VALUES,
FEEDBACK_SOURCE_VALUES,
FEEDBACK_SURVEY_ID_VALUES,
FEEDBACK_TYPE_VALUES,
FEEDBACK_USAGE_TYPE_VALUES,
} from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { captureFeedback, type FeedbackEventProperties } from "../lib/analytics.js";
import { analyticsEnabled } from "../lib/analytics-gate.js";
import { requireAuth } from "../plugins/auth.js";
const toolIdSchema = z
.string()
.trim()
.regex(/^[a-z0-9-]{1,80}$/);
function stripUnsafeControlCharacters(value: string): string {
let out = "";
for (const char of value) {
const code = char.charCodeAt(0);
if (char === "\n" || char === "\t" || (code >= 32 && code !== 127)) {
out += char;
}
}
return out;
}
const optionalText = (max: number) =>
z.string().trim().max(max).transform(stripUnsafeControlCharacters).optional();
const feedbackBodySchema = z
.object({
source: z.enum(FEEDBACK_SOURCE_VALUES),
surveyId: z.enum(FEEDBACK_SURVEY_ID_VALUES).optional(),
promptVariant: z
.string()
.trim()
.max(80)
.regex(/^[a-z0-9_-]+$/)
.optional(),
sentiment: z.enum(FEEDBACK_SENTIMENT_VALUES).optional(),
feedbackType: z.enum(FEEDBACK_TYPE_VALUES).optional(),
message: optionalText(2000),
contactOk: z.boolean().default(false),
contactEmail: z.union([z.string().trim().email().max(320), z.literal("")]).optional(),
contactName: optionalText(120),
company: optionalText(160),
toolId: toolIdSchema.optional(),
searchQuery: optionalText(200),
jobStatus: z.enum(["completed", "failed"]).optional(),
installMethod: z.enum(FEEDBACK_INSTALL_METHOD_VALUES).optional(),
usageType: z.enum(FEEDBACK_USAGE_TYPE_VALUES).optional(),
importantAreas: z.array(z.enum(FEEDBACK_IMPORTANT_AREA_VALUES)).max(5).optional(),
frictionArea: z.enum(FEEDBACK_FRICTION_AREA_VALUES).optional(),
errorCategory: z.enum(FEEDBACK_ERROR_CATEGORY_VALUES).optional(),
})
.superRefine((value, ctx) => {
const hasText = Boolean(value.message?.trim());
const hasChoice = Boolean(
value.sentiment ||
value.feedbackType ||
value.installMethod ||
value.usageType ||
value.searchQuery,
);
if (!hasText && !hasChoice) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Feedback must include a rating, type, or message.",
path: ["message"],
});
}
});
function toPostHogProperties(body: z.infer<typeof feedbackBodySchema>): FeedbackEventProperties {
return {
source: body.source,
survey_id: body.surveyId,
prompt_variant: body.promptVariant,
sentiment: body.sentiment,
feedback_type: body.feedbackType,
message: body.message || undefined,
contact_ok: body.contactOk,
contact_email: body.contactOk ? body.contactEmail || undefined : undefined,
contact_name: body.contactOk ? body.contactName || undefined : undefined,
company: body.contactOk ? body.company || undefined : undefined,
tool_id: body.toolId,
search_query: body.searchQuery,
job_status: body.jobStatus,
install_method: body.installMethod,
usage_type: body.usageType,
important_areas: body.importantAreas,
friction_area: body.frictionArea,
error_category: body.errorCategory,
};
}
export async function feedbackRoutes(app: FastifyInstance): Promise<void> {
app.post(
"/api/v1/feedback",
{ config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const parsed = feedbackBodySchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: "Invalid feedback payload",
code: "VALIDATION_ERROR",
details: parsed.error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
})),
});
}
if (!analyticsEnabled()) {
return reply.send({ ok: true, accepted: false });
}
await captureFeedback(
toPostHogProperties(parsed.data),
request.headers["x-posthog-distinct-id"] as string | undefined,
);
return reply.send({ ok: true, accepted: true });
},
);
app.log.info("Feedback routes registered");
}