diff --git a/bruno/forms/getFormAnalytics.bru b/bruno/forms/getFormAnalytics.bru new file mode 100644 index 0000000..eb41c5b --- /dev/null +++ b/bruno/forms/getFormAnalytics.bru @@ -0,0 +1,41 @@ +meta { + name: getFormAnalytics + type: http + seq: 9 +} + +post { + url: http://localhost:8000/forms/:formId/analytics + body: none + auth: inherit +} + +params:path { + formId: {{formId}} +} + +settings { + encodeUrl: true + timeout: 60000 +} + +docs { + # Get Form Analytics (JSON) + + Generates an AI-powered analytics report for all submitted responses of a form. + Only the form owner can access this endpoint. + + * **URL:** `/forms/:formId/analytics` + * **Method:** `POST` + * **Auth:** Required (session cookie) + + ## Path Parameters + * `formId` — UUID of the form to analyze + + ## Response + Returns a JSON object with: + * `totalResponsesAnalyzed` — number of responses analyzed + * `executiveSummary` — high-level paragraph summarizing sentiment + * `quantitativeInsights` — array of metric breakdowns per question + * `qualitativeThemes` — array of recurring themes found in responses +} diff --git a/bruno/forms/getFormAnalyticsPdf.bru b/bruno/forms/getFormAnalyticsPdf.bru new file mode 100644 index 0000000..7765a7e --- /dev/null +++ b/bruno/forms/getFormAnalyticsPdf.bru @@ -0,0 +1,45 @@ +meta { + name: getFormAnalyticsPdf + type: http + seq: 10 +} + +post { + url: http://localhost:8000/forms/:formId/analytics?format=pdf + body: none + auth: inherit +} + +params:query { + format: pdf +} + +params:path { + formId: {{formId}} +} + +settings { + encodeUrl: true + timeout: 60000 +} + +docs { + # Get Form Analytics (PDF) + + Same as getFormAnalytics but returns a downloadable PDF report. + + * **URL:** `/forms/:formId/analytics?format=pdf` + * **Method:** `POST` + * **Auth:** Required (session cookie) + + ## Path Parameters + * `formId` — UUID of the form to analyze + + ## Query Parameters + * `format` — set to `pdf` to get a PDF download + + ## Response + Returns a PDF file with: + * Content-Type: application/pdf + * Content-Disposition: attachment; filename="analytics-report.pdf" +} diff --git a/bun.lock b/bun.lock index b32f69d..b399855 100644 --- a/bun.lock +++ b/bun.lock @@ -9,9 +9,11 @@ "@google/generative-ai": "^0.24.1", "@prisma/adapter-pg": "^7.4.2", "@prisma/client": "^7.4.2", + "@types/pdfkit": "^0.17.5", "better-auth": "^1.5.4", "elysia": "^1.4.27", "nodemailer": "^8.0.2", + "pdfkit": "^0.17.2", "pg": "^8.20.0", "pino": "^10.3.1", }, @@ -132,6 +134,8 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="], + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], @@ -142,6 +146,8 @@ "@types/nodemailer": ["@types/nodemailer@7.0.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g=="], + "@types/pdfkit": ["@types/pdfkit@0.17.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-T3ZHnvF91HsEco5ClhBCOuBwobZfPcI2jaiSHybkkKYq4KhVIIurod94JVKvDIG0JXT6o3KiERC0X0//m8dyrg=="], + "@types/pg": ["@types/pg@8.18.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q=="], "@types/react": ["@types/react@19.2.8", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="], @@ -160,12 +166,16 @@ "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + "base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], + "better-auth": ["better-auth@1.5.4", "", { "dependencies": { "@better-auth/core": "1.5.4", "@better-auth/drizzle-adapter": "1.5.4", "@better-auth/kysely-adapter": "1.5.4", "@better-auth/memory-adapter": "1.5.4", "@better-auth/mongo-adapter": "1.5.4", "@better-auth/prisma-adapter": "1.5.4", "@better-auth/telemetry": "1.5.4", "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.2", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.11", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-ReykcEKx6Kp9560jG1wtlDBnftA7L7xb3ZZdDWm5yGXKKe2pUf+oBjH0fqekrkRII0m4XBVQbQ0mOrFv+3FdYg=="], "better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="], + "bson": ["bson@7.2.0", "", {}, "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ=="], "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], @@ -182,6 +192,8 @@ "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], + "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], @@ -194,6 +206,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "crypto-js": ["crypto-js@4.2.0", "", {}, "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], @@ -208,6 +222,8 @@ "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + "dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="], + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="], @@ -236,12 +252,16 @@ "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], "file-type": ["file-type@21.3.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], @@ -284,10 +304,14 @@ "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + "jpeg-exif": ["jpeg-exif@1.1.4", "", {}, "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ=="], + "kysely": ["kysely@0.28.11", "", {}, "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg=="], "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], + "linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="], + "lint-staged": ["lint-staged@16.3.2", "", { "dependencies": { "commander": "^14.0.3", "listr2": "^9.0.5", "micromatch": "^4.0.8", "string-argv": "^0.3.2", "tinyexec": "^1.0.2", "yaml": "^2.8.2" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-xKqhC2AeXLwiAHXguxBjuChoTTWFC6Pees0SHPwOpwlvI3BH7ZADFPddAdN3pgo3aiKgPUx/bxE78JfUnxQnlg=="], "listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="], @@ -338,10 +362,14 @@ "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pdfkit": ["pdfkit@0.17.2", "", { "dependencies": { "crypto-js": "^4.2.0", "fontkit": "^2.0.4", "jpeg-exif": "^1.1.4", "linebreak": "^1.1.0", "png-js": "^1.0.0" } }, "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw=="], + "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], "pg": ["pg@8.20.0", "", { "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA=="], @@ -372,6 +400,8 @@ "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], + "png-js": ["png-js@1.0.0", "", {}, "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="], + "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], "postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="], @@ -412,6 +442,8 @@ "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + "restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="], + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], @@ -460,6 +492,8 @@ "thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -468,12 +502,18 @@ "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="], + + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], + "valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], @@ -500,10 +540,14 @@ "@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="], + "brotli/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "unicode-properties/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], } } diff --git a/package.json b/package.json index f8fb5c1..cbd4b0c 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,11 @@ "@google/generative-ai": "^0.24.1", "@prisma/adapter-pg": "^7.4.2", "@prisma/client": "^7.4.2", + "@types/pdfkit": "^0.17.5", "better-auth": "^1.5.4", "elysia": "^1.4.27", "nodemailer": "^8.0.2", + "pdfkit": "^0.17.2", "pg": "^8.20.0", "pino": "^10.3.1" }, diff --git a/src/api/form-analytics/controller.ts b/src/api/form-analytics/controller.ts new file mode 100644 index 0000000..244b7c7 --- /dev/null +++ b/src/api/form-analytics/controller.ts @@ -0,0 +1,288 @@ +import PDFDocument from "pdfkit"; +import { prisma } from "../../db/prisma"; +import { logger } from "../../logger/"; +import type { + AnalyticsReport, + FormAnalyticsContext, +} from "../../types/form-analytics"; + +const GROQ_API_KEY = process.env.GROQ_API_KEY; +const GROQ_MODEL = process.env.GROQ_MODEL || "llama-3.3-70b-versatile"; +const GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions"; + +const ANALYTICS_SYSTEM_PROMPT = `You are an analytics engine for a form-response platform called FormEngine. +You will receive a JSON array of form responses (each containing answers as key-value pairs). +Analyze all responses and produce ONLY valid JSON matching this exact schema — no markdown, no explanation: + +{ + "totalResponsesAnalyzed": , + "executiveSummary": "", + "quantitativeInsights": [ + { + "question": "", + "metric": "", + "value": "" + } + ], + "qualitativeThemes": [ + { + "theme": "", + "description": "", + "frequency": "" + } + ] +} + +RULES: +1. Respond with ONLY the JSON object. No markdown fences, no explanation. +2. totalResponsesAnalyzed must equal the number of responses provided. +3. Provide at least 1 quantitativeInsight and 1 qualitativeTheme. +4. For numeric fields, compute averages, min, max, or distributions where applicable. +5. For text/choice fields, identify common themes, most popular choices, and sentiment. +6. The executiveSummary should be a well-written paragraph of 3-5 sentences.`; + +async function callGroqForAnalytics( + responsesJson: string, +): Promise { + const response = await fetch(GROQ_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${GROQ_API_KEY}`, + }, + body: JSON.stringify({ + model: GROQ_MODEL, + messages: [ + { role: "system", content: ANALYTICS_SYSTEM_PROMPT }, + { + role: "user", + content: `Analyze the following form responses:\n${responsesJson}`, + }, + ], + response_format: { type: "json_object" }, + temperature: 0.3, + }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error( + `Groq API error ${response.status}: ${errorBody.slice(0, 300)}`, + ); + } + + const data = (await response.json()) as { + choices: Array<{ message: { content: string } }>; + }; + + const content = data.choices?.[0]?.message?.content; + if (!content) { + throw new Error("Groq returned empty response"); + } + + return JSON.parse(content) as AnalyticsReport; +} + +function generatePdfBuffer( + report: AnalyticsReport, + formTitle: string, +): Promise { + return new Promise((resolve, reject) => { + const doc = new PDFDocument({ margin: 50 }); + const chunks: Uint8Array[] = []; + + doc.on("data", (chunk: Uint8Array) => chunks.push(chunk)); + doc.on("end", () => resolve(Buffer.concat(chunks))); + doc.on("error", reject); + + // Title + doc + .fontSize(22) + .font("Helvetica-Bold") + .text(formTitle, { align: "center" }); + doc.moveDown(0.3); + doc + .fontSize(12) + .font("Helvetica") + .text("Analytics Report", { align: "center" }); + doc.moveDown(0.3); + doc + .fontSize(10) + .fillColor("#666666") + .text(`Generated on ${new Date().toLocaleDateString()}`, { + align: "center", + }); + doc.fillColor("#000000"); + doc.moveDown(1); + + // Divider + doc + .moveTo(50, doc.y) + .lineTo(doc.page.width - 50, doc.y) + .stroke("#cccccc"); + doc.moveDown(1); + + // Executive Summary + doc.fontSize(16).font("Helvetica-Bold").text("Executive Summary"); + doc.moveDown(0.5); + doc + .fontSize(11) + .font("Helvetica") + .text(report.executiveSummary, { lineGap: 4 }); + doc.moveDown(1); + + // Total Responses + doc + .fontSize(11) + .font("Helvetica-Bold") + .text(`Total Responses Analyzed: ${report.totalResponsesAnalyzed}`); + doc.moveDown(1); + + // Quantitative Insights + doc.fontSize(16).font("Helvetica-Bold").text("Quantitative Insights"); + doc.moveDown(0.5); + + for (const insight of report.quantitativeInsights) { + doc.fontSize(12).font("Helvetica-Bold").text(insight.question); + doc + .fontSize(11) + .font("Helvetica") + .text(`${insight.metric}: ${insight.value}`); + doc.moveDown(0.5); + } + + doc.moveDown(0.5); + + // Qualitative Themes + doc.fontSize(16).font("Helvetica-Bold").text("Qualitative Themes"); + doc.moveDown(0.5); + + for (const theme of report.qualitativeThemes) { + doc.fontSize(12).font("Helvetica-Bold").text(theme.theme); + doc + .fontSize(11) + .font("Helvetica") + .text(theme.description, { lineGap: 3 }); + doc + .fontSize(10) + .fillColor("#666666") + .text(`Frequency: ${theme.frequency}`); + doc.fillColor("#000000"); + doc.moveDown(0.5); + } + + doc.end(); + }); +} + +export async function getFormAnalytics({ + user, + params, + query, + set, +}: FormAnalyticsContext) { + // 1. Verify the form exists and the user is the owner + const form = await prisma.form.findUnique({ + where: { id: params.formId }, + select: { + id: true, + title: true, + ownerId: true, + }, + }); + + if (!form) { + set.status = 404; + return { success: false, message: "Form not found" }; + } + + if (form.ownerId !== user.id) { + set.status = 403; + return { + success: false, + message: "Forbidden: you are not the owner of this form", + }; + } + + // 2. Fetch all submitted responses + const responses = await prisma.formResponse.findMany({ + where: { + formId: params.formId, + isSubmitted: true, + }, + select: { + id: true, + answers: true, + submittedAt: true, + }, + }); + + if (responses.length === 0) { + set.status = 404; + return { + success: false, + message: "No submitted responses found for this form", + }; + } + + // 3. Map fieldIds in answers to fieldNames for better AI context + const fields = await prisma.formFields.findMany({ + where: { formId: params.formId }, + select: { id: true, fieldName: true, label: true }, + }); + + const fieldIdToLabel = Object.fromEntries( + fields.map((f) => [f.id, f.label || f.fieldName]), + ); + + const transformedResponses = responses.map((r) => { + const answers = r.answers as Record; + const labeled: Record = {}; + for (const [fieldId, value] of Object.entries(answers)) { + const label = fieldIdToLabel[fieldId] ?? fieldId; + labeled[label] = value; + } + return labeled; + }); + + // 4. Call Groq for AI analytics + if (!GROQ_API_KEY) { + set.status = 503; + return { success: false, message: "AI service is not configured" }; + } + + let report: AnalyticsReport; + try { + report = await callGroqForAnalytics(JSON.stringify(transformedResponses)); + } catch (err) { + logger.error("Analytics AI call failed", err); + set.status = 502; + return { + success: false, + message: "Failed to generate analytics report. Please try again.", + }; + } + + logger.info("Generated analytics report", { + userId: user.id, + formId: params.formId, + responseCount: responses.length, + }); + + // 5. Return JSON or PDF based on format query param + if (query.format === "pdf") { + const pdfBuffer = await generatePdfBuffer(report, form.title); + + set.headers["Content-Type"] = "application/pdf"; + set.headers["Content-Disposition"] = + 'attachment; filename="analytics-report.pdf"'; + + return new Response(new Uint8Array(pdfBuffer)); + } + + return { + success: true, + message: "Analytics report generated successfully", + data: report, + }; +} diff --git a/src/api/form-analytics/routes.ts b/src/api/form-analytics/routes.ts new file mode 100644 index 0000000..1d0d801 --- /dev/null +++ b/src/api/form-analytics/routes.ts @@ -0,0 +1,8 @@ +import { Elysia } from "elysia"; +import { formAnalyticsDTO } from "../../types/form-analytics"; +import { requireAuth } from "../auth/requireAuth"; +import { getFormAnalytics } from "./controller"; + +export const formAnalyticsRoutes = new Elysia({ prefix: "/forms" }) + .use(requireAuth) + .post("/:formId/analytics", getFormAnalytics, formAnalyticsDTO); diff --git a/src/index.ts b/src/index.ts index 7bede49..eb4dc38 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { cors } from "@elysiajs/cors"; import { Elysia } from "elysia"; import { auth } from "./api/auth/index"; import { authRoutes } from "./api/auth/routes"; +import { formAnalyticsRoutes } from "./api/form-analytics/routes"; import { formFieldRoutes, publicFormFieldRoutes, @@ -73,7 +74,8 @@ const app = new Elysia() .use(publicFormFieldRoutes) // Public form fields (no auth) .use(formRoutes) .use(formFieldRoutes) - .use(formResponseRoutes); + .use(formResponseRoutes) + .use(formAnalyticsRoutes); const port = Number(process.env.PORT) || 8000; app.listen({ diff --git a/src/types/form-analytics.ts b/src/types/form-analytics.ts new file mode 100644 index 0000000..ff26b41 --- /dev/null +++ b/src/types/form-analytics.ts @@ -0,0 +1,35 @@ +import { type Static, t } from "elysia"; + +export const formAnalyticsDTO = { + params: t.Object({ + formId: t.String({ format: "uuid" }), + }), + query: t.Object({ + format: t.Optional(t.String()), + }), +}; + +export interface FormAnalyticsContext { + user: { id: string }; + params: Static; + query: Static; + set: { + status?: number | string; + headers: Record; + }; +} + +export interface AnalyticsReport { + totalResponsesAnalyzed: number; + executiveSummary: string; + quantitativeInsights: Array<{ + question: string; + metric: string; + value: string | number; + }>; + qualitativeThemes: Array<{ + theme: string; + description: string; + frequency: string; + }>; +}