Skip to content

Commit 5e637c7

Browse files
authored
Merge pull request #43 from Nandgopal-R/feat/ai
feat: add ai analytics generator
2 parents 8ea9c7a + e3dd798 commit 5e637c7

File tree

8 files changed

+466
-1
lines changed

8 files changed

+466
-1
lines changed

bruno/forms/getFormAnalytics.bru

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
meta {
2+
name: getFormAnalytics
3+
type: http
4+
seq: 9
5+
}
6+
7+
post {
8+
url: http://localhost:8000/forms/:formId/analytics
9+
body: none
10+
auth: inherit
11+
}
12+
13+
params:path {
14+
formId: {{formId}}
15+
}
16+
17+
settings {
18+
encodeUrl: true
19+
timeout: 60000
20+
}
21+
22+
docs {
23+
# Get Form Analytics (JSON)
24+
25+
Generates an AI-powered analytics report for all submitted responses of a form.
26+
Only the form owner can access this endpoint.
27+
28+
* **URL:** `/forms/:formId/analytics`
29+
* **Method:** `POST`
30+
* **Auth:** Required (session cookie)
31+
32+
## Path Parameters
33+
* `formId` — UUID of the form to analyze
34+
35+
## Response
36+
Returns a JSON object with:
37+
* `totalResponsesAnalyzed` — number of responses analyzed
38+
* `executiveSummary` — high-level paragraph summarizing sentiment
39+
* `quantitativeInsights` — array of metric breakdowns per question
40+
* `qualitativeThemes` — array of recurring themes found in responses
41+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
meta {
2+
name: getFormAnalyticsPdf
3+
type: http
4+
seq: 10
5+
}
6+
7+
post {
8+
url: http://localhost:8000/forms/:formId/analytics?format=pdf
9+
body: none
10+
auth: inherit
11+
}
12+
13+
params:query {
14+
format: pdf
15+
}
16+
17+
params:path {
18+
formId: {{formId}}
19+
}
20+
21+
settings {
22+
encodeUrl: true
23+
timeout: 60000
24+
}
25+
26+
docs {
27+
# Get Form Analytics (PDF)
28+
29+
Same as getFormAnalytics but returns a downloadable PDF report.
30+
31+
* **URL:** `/forms/:formId/analytics?format=pdf`
32+
* **Method:** `POST`
33+
* **Auth:** Required (session cookie)
34+
35+
## Path Parameters
36+
* `formId` — UUID of the form to analyze
37+
38+
## Query Parameters
39+
* `format` — set to `pdf` to get a PDF download
40+
41+
## Response
42+
Returns a PDF file with:
43+
* Content-Type: application/pdf
44+
* Content-Disposition: attachment; filename="analytics-report.pdf"
45+
}

bun.lock

Lines changed: 44 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@
1818
"@google/generative-ai": "^0.24.1",
1919
"@prisma/adapter-pg": "^7.4.2",
2020
"@prisma/client": "^7.4.2",
21+
"@types/pdfkit": "^0.17.5",
2122
"better-auth": "^1.5.4",
2223
"elysia": "^1.4.27",
2324
"nodemailer": "^8.0.2",
25+
"pdfkit": "^0.17.2",
2426
"pg": "^8.20.0",
2527
"pino": "^10.3.1"
2628
},
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
import PDFDocument from "pdfkit";
2+
import { prisma } from "../../db/prisma";
3+
import { logger } from "../../logger/";
4+
import type {
5+
AnalyticsReport,
6+
FormAnalyticsContext,
7+
} from "../../types/form-analytics";
8+
9+
const GROQ_API_KEY = process.env.GROQ_API_KEY;
10+
const GROQ_MODEL = process.env.GROQ_MODEL || "llama-3.3-70b-versatile";
11+
const GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions";
12+
13+
const ANALYTICS_SYSTEM_PROMPT = `You are an analytics engine for a form-response platform called FormEngine.
14+
You will receive a JSON array of form responses (each containing answers as key-value pairs).
15+
Analyze all responses and produce ONLY valid JSON matching this exact schema — no markdown, no explanation:
16+
17+
{
18+
"totalResponsesAnalyzed": <number>,
19+
"executiveSummary": "<A high-level paragraph summarizing overall sentiment, trends, and key takeaways from the responses>",
20+
"quantitativeInsights": [
21+
{
22+
"question": "<the form field/question label>",
23+
"metric": "<what is being measured, e.g. 'Average', 'Most Common', 'Distribution'>",
24+
"value": "<computed value — string or number>"
25+
}
26+
],
27+
"qualitativeThemes": [
28+
{
29+
"theme": "<short theme name>",
30+
"description": "<description of this theme found across responses>",
31+
"frequency": "<how often this theme appears, e.g. '65% of responses', '12 out of 20'>"
32+
}
33+
]
34+
}
35+
36+
RULES:
37+
1. Respond with ONLY the JSON object. No markdown fences, no explanation.
38+
2. totalResponsesAnalyzed must equal the number of responses provided.
39+
3. Provide at least 1 quantitativeInsight and 1 qualitativeTheme.
40+
4. For numeric fields, compute averages, min, max, or distributions where applicable.
41+
5. For text/choice fields, identify common themes, most popular choices, and sentiment.
42+
6. The executiveSummary should be a well-written paragraph of 3-5 sentences.`;
43+
44+
async function callGroqForAnalytics(
45+
responsesJson: string,
46+
): Promise<AnalyticsReport> {
47+
const response = await fetch(GROQ_API_URL, {
48+
method: "POST",
49+
headers: {
50+
"Content-Type": "application/json",
51+
Authorization: `Bearer ${GROQ_API_KEY}`,
52+
},
53+
body: JSON.stringify({
54+
model: GROQ_MODEL,
55+
messages: [
56+
{ role: "system", content: ANALYTICS_SYSTEM_PROMPT },
57+
{
58+
role: "user",
59+
content: `Analyze the following form responses:\n${responsesJson}`,
60+
},
61+
],
62+
response_format: { type: "json_object" },
63+
temperature: 0.3,
64+
}),
65+
});
66+
67+
if (!response.ok) {
68+
const errorBody = await response.text();
69+
throw new Error(
70+
`Groq API error ${response.status}: ${errorBody.slice(0, 300)}`,
71+
);
72+
}
73+
74+
const data = (await response.json()) as {
75+
choices: Array<{ message: { content: string } }>;
76+
};
77+
78+
const content = data.choices?.[0]?.message?.content;
79+
if (!content) {
80+
throw new Error("Groq returned empty response");
81+
}
82+
83+
return JSON.parse(content) as AnalyticsReport;
84+
}
85+
86+
function generatePdfBuffer(
87+
report: AnalyticsReport,
88+
formTitle: string,
89+
): Promise<Buffer> {
90+
return new Promise((resolve, reject) => {
91+
const doc = new PDFDocument({ margin: 50 });
92+
const chunks: Uint8Array[] = [];
93+
94+
doc.on("data", (chunk: Uint8Array) => chunks.push(chunk));
95+
doc.on("end", () => resolve(Buffer.concat(chunks)));
96+
doc.on("error", reject);
97+
98+
// Title
99+
doc
100+
.fontSize(22)
101+
.font("Helvetica-Bold")
102+
.text(formTitle, { align: "center" });
103+
doc.moveDown(0.3);
104+
doc
105+
.fontSize(12)
106+
.font("Helvetica")
107+
.text("Analytics Report", { align: "center" });
108+
doc.moveDown(0.3);
109+
doc
110+
.fontSize(10)
111+
.fillColor("#666666")
112+
.text(`Generated on ${new Date().toLocaleDateString()}`, {
113+
align: "center",
114+
});
115+
doc.fillColor("#000000");
116+
doc.moveDown(1);
117+
118+
// Divider
119+
doc
120+
.moveTo(50, doc.y)
121+
.lineTo(doc.page.width - 50, doc.y)
122+
.stroke("#cccccc");
123+
doc.moveDown(1);
124+
125+
// Executive Summary
126+
doc.fontSize(16).font("Helvetica-Bold").text("Executive Summary");
127+
doc.moveDown(0.5);
128+
doc
129+
.fontSize(11)
130+
.font("Helvetica")
131+
.text(report.executiveSummary, { lineGap: 4 });
132+
doc.moveDown(1);
133+
134+
// Total Responses
135+
doc
136+
.fontSize(11)
137+
.font("Helvetica-Bold")
138+
.text(`Total Responses Analyzed: ${report.totalResponsesAnalyzed}`);
139+
doc.moveDown(1);
140+
141+
// Quantitative Insights
142+
doc.fontSize(16).font("Helvetica-Bold").text("Quantitative Insights");
143+
doc.moveDown(0.5);
144+
145+
for (const insight of report.quantitativeInsights) {
146+
doc.fontSize(12).font("Helvetica-Bold").text(insight.question);
147+
doc
148+
.fontSize(11)
149+
.font("Helvetica")
150+
.text(`${insight.metric}: ${insight.value}`);
151+
doc.moveDown(0.5);
152+
}
153+
154+
doc.moveDown(0.5);
155+
156+
// Qualitative Themes
157+
doc.fontSize(16).font("Helvetica-Bold").text("Qualitative Themes");
158+
doc.moveDown(0.5);
159+
160+
for (const theme of report.qualitativeThemes) {
161+
doc.fontSize(12).font("Helvetica-Bold").text(theme.theme);
162+
doc
163+
.fontSize(11)
164+
.font("Helvetica")
165+
.text(theme.description, { lineGap: 3 });
166+
doc
167+
.fontSize(10)
168+
.fillColor("#666666")
169+
.text(`Frequency: ${theme.frequency}`);
170+
doc.fillColor("#000000");
171+
doc.moveDown(0.5);
172+
}
173+
174+
doc.end();
175+
});
176+
}
177+
178+
export async function getFormAnalytics({
179+
user,
180+
params,
181+
query,
182+
set,
183+
}: FormAnalyticsContext) {
184+
// 1. Verify the form exists and the user is the owner
185+
const form = await prisma.form.findUnique({
186+
where: { id: params.formId },
187+
select: {
188+
id: true,
189+
title: true,
190+
ownerId: true,
191+
},
192+
});
193+
194+
if (!form) {
195+
set.status = 404;
196+
return { success: false, message: "Form not found" };
197+
}
198+
199+
if (form.ownerId !== user.id) {
200+
set.status = 403;
201+
return {
202+
success: false,
203+
message: "Forbidden: you are not the owner of this form",
204+
};
205+
}
206+
207+
// 2. Fetch all submitted responses
208+
const responses = await prisma.formResponse.findMany({
209+
where: {
210+
formId: params.formId,
211+
isSubmitted: true,
212+
},
213+
select: {
214+
id: true,
215+
answers: true,
216+
submittedAt: true,
217+
},
218+
});
219+
220+
if (responses.length === 0) {
221+
set.status = 404;
222+
return {
223+
success: false,
224+
message: "No submitted responses found for this form",
225+
};
226+
}
227+
228+
// 3. Map fieldIds in answers to fieldNames for better AI context
229+
const fields = await prisma.formFields.findMany({
230+
where: { formId: params.formId },
231+
select: { id: true, fieldName: true, label: true },
232+
});
233+
234+
const fieldIdToLabel = Object.fromEntries(
235+
fields.map((f) => [f.id, f.label || f.fieldName]),
236+
);
237+
238+
const transformedResponses = responses.map((r) => {
239+
const answers = r.answers as Record<string, unknown>;
240+
const labeled: Record<string, unknown> = {};
241+
for (const [fieldId, value] of Object.entries(answers)) {
242+
const label = fieldIdToLabel[fieldId] ?? fieldId;
243+
labeled[label] = value;
244+
}
245+
return labeled;
246+
});
247+
248+
// 4. Call Groq for AI analytics
249+
if (!GROQ_API_KEY) {
250+
set.status = 503;
251+
return { success: false, message: "AI service is not configured" };
252+
}
253+
254+
let report: AnalyticsReport;
255+
try {
256+
report = await callGroqForAnalytics(JSON.stringify(transformedResponses));
257+
} catch (err) {
258+
logger.error("Analytics AI call failed", err);
259+
set.status = 502;
260+
return {
261+
success: false,
262+
message: "Failed to generate analytics report. Please try again.",
263+
};
264+
}
265+
266+
logger.info("Generated analytics report", {
267+
userId: user.id,
268+
formId: params.formId,
269+
responseCount: responses.length,
270+
});
271+
272+
// 5. Return JSON or PDF based on format query param
273+
if (query.format === "pdf") {
274+
const pdfBuffer = await generatePdfBuffer(report, form.title);
275+
276+
set.headers["Content-Type"] = "application/pdf";
277+
set.headers["Content-Disposition"] =
278+
'attachment; filename="analytics-report.pdf"';
279+
280+
return new Response(new Uint8Array(pdfBuffer));
281+
}
282+
283+
return {
284+
success: true,
285+
message: "Analytics report generated successfully",
286+
data: report,
287+
};
288+
}

src/api/form-analytics/routes.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Elysia } from "elysia";
2+
import { formAnalyticsDTO } from "../../types/form-analytics";
3+
import { requireAuth } from "../auth/requireAuth";
4+
import { getFormAnalytics } from "./controller";
5+
6+
export const formAnalyticsRoutes = new Elysia({ prefix: "/forms" })
7+
.use(requireAuth)
8+
.post("/:formId/analytics", getFormAnalytics, formAnalyticsDTO);

0 commit comments

Comments
 (0)