mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add PostHog customer feedback
This commit is contained in:
@@ -50,6 +50,7 @@ import { configRoutes } from "./routes/config.js";
|
||||
import { docsRoutes } from "./routes/docs.js";
|
||||
import { registerEnterpriseRoutes } from "./routes/enterprise/index.js";
|
||||
import { registerFeatureRoutes } from "./routes/features.js";
|
||||
import { feedbackRoutes } from "./routes/feedback.js";
|
||||
import { registerFetchUrlsRoute } from "./routes/fetch-urls.js";
|
||||
import { filePreviewRoutes } from "./routes/file-preview.js";
|
||||
import { fileRoutes } from "./routes/files.js";
|
||||
@@ -438,6 +439,9 @@ await settingsRoutes(app);
|
||||
// Analytics config and consent routes
|
||||
await analyticsRoutes(app);
|
||||
|
||||
// Explicit customer feedback capture (respects the analytics gate)
|
||||
await feedbackRoutes(app);
|
||||
|
||||
// Feature management routes (AI feature bundle install/uninstall)
|
||||
await registerFeatureRoutes(app);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ANALYTICS_BAKED } from "@snapotter/shared";
|
||||
import { ANALYTICS_BAKED, ANALYTICS_EVENTS, APP_VERSION } from "@snapotter/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { PostHog } from "posthog-node";
|
||||
import { db, schema } from "../db/index.js";
|
||||
@@ -7,6 +7,43 @@ import { analyticsEnabled, bakedEnabled } from "./analytics-gate.js";
|
||||
|
||||
let posthogClient: PostHog | null = null;
|
||||
|
||||
export interface FeedbackEventProperties {
|
||||
source: "global" | "tool_result" | "failed_job" | "admin_installer";
|
||||
survey_id?: "global-feedback-v1" | "tool-result-v1" | "failed-job-v1" | "admin-install-v1";
|
||||
prompt_variant?: string;
|
||||
sentiment?: "great" | "okay" | "issue" | "missing" | "bug" | "idea" | "other";
|
||||
feedback_type?: "bug" | "feature_request" | "confusing_ux" | "performance" | "other";
|
||||
message?: string;
|
||||
contact_ok: boolean;
|
||||
contact_email?: string;
|
||||
contact_name?: string;
|
||||
company?: string;
|
||||
tool_id?: string;
|
||||
job_status?: "completed" | "failed";
|
||||
install_method?: "docker" | "docker_compose" | "source" | "cloud" | "other";
|
||||
usage_type?: "personal" | "team_internal" | "business_workflow" | "education" | "evaluating";
|
||||
important_areas?: string[];
|
||||
friction_area?:
|
||||
| "smooth"
|
||||
| "docker"
|
||||
| "environment_variables"
|
||||
| "auth"
|
||||
| "storage"
|
||||
| "workers"
|
||||
| "ai_tools"
|
||||
| "docs"
|
||||
| "performance"
|
||||
| "other";
|
||||
error_category?:
|
||||
| "validation_error"
|
||||
| "upload_error"
|
||||
| "processing_error"
|
||||
| "timeout"
|
||||
| "unsupported_format"
|
||||
| "worker_unavailable"
|
||||
| "unknown";
|
||||
}
|
||||
|
||||
export async function initAnalytics(): Promise<void> {
|
||||
if (!bakedEnabled()) return;
|
||||
|
||||
@@ -68,3 +105,54 @@ export async function trackEvent(
|
||||
// analytics must never throw
|
||||
}
|
||||
}
|
||||
|
||||
function cleanFeedbackProperties(properties: FeedbackEventProperties): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {
|
||||
feedback_version: 1,
|
||||
app_version: APP_VERSION,
|
||||
source: properties.source,
|
||||
contact_ok: properties.contact_ok,
|
||||
};
|
||||
|
||||
const copyString = (from: keyof FeedbackEventProperties, to = from) => {
|
||||
const value = properties[from];
|
||||
if (typeof value === "string" && value.length > 0) out[to] = value;
|
||||
};
|
||||
|
||||
copyString("survey_id");
|
||||
copyString("prompt_variant");
|
||||
copyString("sentiment");
|
||||
copyString("feedback_type");
|
||||
copyString("message");
|
||||
copyString("contact_email");
|
||||
copyString("contact_name");
|
||||
copyString("company");
|
||||
copyString("tool_id");
|
||||
copyString("job_status");
|
||||
copyString("install_method");
|
||||
copyString("usage_type");
|
||||
copyString("friction_area");
|
||||
copyString("error_category");
|
||||
|
||||
if (properties.important_areas?.length) {
|
||||
out.important_areas = properties.important_areas;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function captureFeedback(
|
||||
properties: FeedbackEventProperties,
|
||||
distinctId?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!analyticsEnabled() || !posthogClient) return;
|
||||
posthogClient.capture({
|
||||
distinctId: distinctId ?? (await getInstanceId()),
|
||||
event: ANALYTICS_EVENTS.FEEDBACK_SUBMITTED,
|
||||
properties: cleanFeedbackProperties(properties),
|
||||
});
|
||||
} catch {
|
||||
// feedback capture must never throw
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
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 SOURCE_VALUES = ["global", "tool_result", "failed_job", "admin_installer"] as const;
|
||||
const SURVEY_ID_VALUES = [
|
||||
"global-feedback-v1",
|
||||
"tool-result-v1",
|
||||
"failed-job-v1",
|
||||
"admin-install-v1",
|
||||
] as const;
|
||||
const SENTIMENT_VALUES = ["great", "okay", "issue", "missing", "bug", "idea", "other"] as const;
|
||||
const FEEDBACK_TYPE_VALUES = [
|
||||
"bug",
|
||||
"feature_request",
|
||||
"confusing_ux",
|
||||
"performance",
|
||||
"other",
|
||||
] as const;
|
||||
const INSTALL_METHOD_VALUES = ["docker", "docker_compose", "source", "cloud", "other"] as const;
|
||||
const USAGE_TYPE_VALUES = [
|
||||
"personal",
|
||||
"team_internal",
|
||||
"business_workflow",
|
||||
"education",
|
||||
"evaluating",
|
||||
] as const;
|
||||
const IMPORTANT_AREA_VALUES = [
|
||||
"images",
|
||||
"pdf_docs",
|
||||
"video_audio",
|
||||
"batch_workflows",
|
||||
"ai_tools",
|
||||
] as const;
|
||||
const FRICTION_AREA_VALUES = [
|
||||
"smooth",
|
||||
"docker",
|
||||
"environment_variables",
|
||||
"auth",
|
||||
"storage",
|
||||
"workers",
|
||||
"ai_tools",
|
||||
"docs",
|
||||
"performance",
|
||||
"other",
|
||||
] as const;
|
||||
const ERROR_CATEGORY_VALUES = [
|
||||
"validation_error",
|
||||
"upload_error",
|
||||
"processing_error",
|
||||
"timeout",
|
||||
"unsupported_format",
|
||||
"worker_unavailable",
|
||||
"unknown",
|
||||
] as const;
|
||||
|
||||
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(SOURCE_VALUES),
|
||||
surveyId: z.enum(SURVEY_ID_VALUES).optional(),
|
||||
promptVariant: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(80)
|
||||
.regex(/^[a-z0-9_-]+$/)
|
||||
.optional(),
|
||||
sentiment: z.enum(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(),
|
||||
jobStatus: z.enum(["completed", "failed"]).optional(),
|
||||
installMethod: z.enum(INSTALL_METHOD_VALUES).optional(),
|
||||
usageType: z.enum(USAGE_TYPE_VALUES).optional(),
|
||||
importantAreas: z.array(z.enum(IMPORTANT_AREA_VALUES)).max(5).optional(),
|
||||
frictionArea: z.enum(FRICTION_AREA_VALUES).optional(),
|
||||
errorCategory: z.enum(ERROR_CATEGORY_VALUES).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const hasText = Boolean(value.message?.trim());
|
||||
const hasChoice = Boolean(
|
||||
value.sentiment || value.feedbackType || value.installMethod || value.usageType,
|
||||
);
|
||||
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,
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user