Add user agent as meta data to responses (#398)

* chore: add ua parser

* feat: add user agent to meta

* feat: parse ua

* feat: add tooltip

* fix: type

* fix: empty tooltip

* fix: typo

* add simple formatting to tooltip text

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Nafees Nazik
2023-06-26 15:12:42 +05:30
committed by GitHub
parent c853f8db2c
commit 55c1e354fc
6 changed files with 203 additions and 38 deletions
+15 -1
View File
@@ -9,6 +9,7 @@ import { getSurvey } from "@formbricks/lib/services/survey";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { TResponseInput, ZResponseInput } from "@formbricks/types/v1/responses";
import { NextResponse } from "next/server";
import { UAParser } from "ua-parser-js";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
@@ -16,6 +17,7 @@ export async function OPTIONS(): Promise<NextResponse> {
export async function POST(request: Request): Promise<NextResponse> {
const responseInput: TResponseInput = await request.json();
const agent = UAParser(request.headers.get("user-agent"));
const inputValidation = ZResponseInput.safeParse(responseInput);
if (!inputValidation.success) {
@@ -72,8 +74,20 @@ export async function POST(request: Request): Promise<NextResponse> {
const teamOwnerId = memberships[0]?.userId;
let response;
try {
response = await createResponse(responseInput);
const meta = {
userAgent: {
browser: agent?.browser.name,
device: agent?.device.type,
os: agent?.os.name,
},
};
response = await createResponse({
...responseInput,
meta,
});
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
@@ -12,7 +12,7 @@ import { CheckCircleIcon } from "@heroicons/react/24/solid";
import clsx from "clsx";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { ReactNode, useState } from "react";
import toast from "react-hot-toast";
import { RatingResponse } from "../RatingResponse";
import ResponseNote from "./ResponseNote";
@@ -30,6 +30,13 @@ export interface OpenTextSummaryProps {
scale?: "number" | "star" | "smiley";
range?: number;
}[];
meta?: {
userAgent?: {
browser?: string;
os?: string;
device?: string;
};
};
};
}
@@ -38,6 +45,28 @@ function findEmail(person) {
return emailAttribute ? emailAttribute.value : null;
}
interface TooltipRendererProps {
shouldRender: boolean;
tooltipContent: ReactNode;
children: ReactNode;
}
function TooltipRenderer(props: TooltipRendererProps) {
const { children, shouldRender, tooltipContent } = props;
if (shouldRender) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>{children}</TooltipTrigger>
<TooltipContent>{tooltipContent}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return <>{children}</>;
}
export default function SingleResponse({ data, environmentId, surveyId }: OpenTextSummaryProps) {
const router = useRouter();
const email = data.person && findEmail(data.person);
@@ -55,16 +84,38 @@ export default function SingleResponse({ data, environmentId, surveyId }: OpenTe
setIsDeleting(false);
};
const tooltipContent = data.personAttributes && Object.keys(data.personAttributes).length > 0 && (
<TooltipContent>
{Object.keys(data.personAttributes).map((key) => {
return (
<p>
{key}: <span className="font-bold">{data.personAttributes && data.personAttributes[key]}</span>
</p>
);
})}
</TooltipContent>
const renderTooltip = Boolean(
(data.personAttributes && Object.keys(data.personAttributes).length > 0) ||
(data.meta?.userAgent && Object.keys(data.meta.userAgent).length > 0)
);
const tooltipContent = (
<>
{data.personAttributes && Object.keys(data.personAttributes).length > 0 && (
<div>
<p className="py-1 font-bold text-slate-700">Person attributes:</p>
{Object.keys(data.personAttributes).map((key) => (
<p key={key}>
{key}: <span className="font-bold">{data.personAttributes && data.personAttributes[key]}</span>
</p>
))}
</div>
)}
{data.meta?.userAgent && Object.keys(data.meta.userAgent).length > 0 && (
<div className="text-slate-600">
{data.personAttributes && Object.keys(data.personAttributes).length > 0 && (
<hr className="my-2 border-slate-200" />
)}
<p className="py-1 font-bold text-slate-700">Device info:</p>
{data.meta?.userAgent?.browser && <p>Browser: {data.meta.userAgent.browser}</p>}
{data.meta?.userAgent?.os && <p>OS: {data.meta.userAgent.os}</p>}
{data.meta?.userAgent && (
<p>Device: {data.meta.userAgent.device ? data.meta.userAgent.device : "PC / Generic device"}</p>
)}
</div>
)}
</>
);
return (
@@ -80,21 +131,18 @@ export default function SingleResponse({ data, environmentId, surveyId }: OpenTe
<Link
className="group flex items-center"
href={`/environments/${environmentId}/people/${data.person.id}`}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<PersonAvatar personId={data.person.id} />
</TooltipTrigger>
{tooltipContent}
</Tooltip>
</TooltipProvider>
<TooltipRenderer shouldRender={renderTooltip} tooltipContent={tooltipContent}>
<PersonAvatar personId={data.person.id} />
</TooltipRenderer>
<h3 className="ph-no-capture ml-4 pb-1 font-semibold text-slate-600 hover:underline">
{displayIdentifier}
</h3>
</Link>
) : (
<div className="group flex items-center">
<PersonAvatar personId="anonymous" />
<TooltipRenderer shouldRender={renderTooltip} tooltipContent={tooltipContent}>
<PersonAvatar personId="anonymous" />
</TooltipRenderer>
<h3 className="ml-4 pb-1 font-semibold text-slate-600">Anonymous</h3>
</div>
)}
+1
View File
@@ -51,6 +51,7 @@
"stripe": "^12.6.0",
"swr": "^2.1.5",
"typescript": "5.0.4",
"ua-parser-js": "^1.0.35",
"zod": "^3.21.4"
},
"devDependencies": {
+2
View File
@@ -12,6 +12,7 @@ const responseSelection = {
surveyId: true,
finished: true,
data: true,
meta: true,
personAttributes: true,
person: {
select: {
@@ -82,6 +83,7 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
},
personAttributes: person?.attributes,
}),
...(responseInput.meta && ({ meta: responseInput?.meta } as Prisma.JsonObject)),
},
select: responseSelection,
});
+11
View File
@@ -52,6 +52,17 @@ export const ZResponseInput = z.object({
personId: z.string().cuid2().nullable(),
finished: z.boolean(),
data: ZResponseData,
meta: z
.object({
userAgent: z
.object({
browser: z.string().optional(),
device: z.string().optional(),
os: z.string().optional(),
})
.optional(),
})
.optional(),
});
export type TResponseInput = z.infer<typeof ZResponseInput>;
+106 -17
View File
@@ -1,9 +1,5 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
@@ -319,6 +315,9 @@ importers:
typescript:
specifier: 5.0.4
version: 5.0.4
ua-parser-js:
specifier: ^1.0.35
version: 1.0.35
zod:
specifier: ^3.21.4
version: 3.21.4
@@ -392,7 +391,7 @@ importers:
version: 5.0.1
tsup:
specifier: ^6.7.0
version: 6.7.0(typescript@5.0.4)
version: 6.7.0
packages/database:
dependencies:
@@ -484,7 +483,7 @@ importers:
version: 5.0.1
tsup:
specifier: ^6.7.0
version: 6.7.0(typescript@5.0.4)
version: 6.7.0
packages/eslint-config-formbricks:
dependencies:
@@ -10404,7 +10403,7 @@ packages:
eslint: 8.41.0
eslint-import-resolver-node: 0.3.6
eslint-import-resolver-typescript: 3.5.2(eslint-plugin-import@2.26.0)(eslint@8.41.0)
eslint-plugin-import: 2.26.0(@typescript-eslint/parser@5.59.8)(eslint-import-resolver-typescript@3.5.2)(eslint@8.41.0)
eslint-plugin-import: 2.26.0(eslint@8.41.0)
eslint-plugin-jsx-a11y: 6.6.1(eslint@8.41.0)
eslint-plugin-react: 7.32.2(eslint@8.41.0)
eslint-plugin-react-hooks: 4.6.0(eslint@8.41.0)
@@ -10429,7 +10428,7 @@ packages:
eslint: 8.41.0
eslint-import-resolver-node: 0.3.6
eslint-import-resolver-typescript: 3.5.2(eslint-plugin-import@2.26.0)(eslint@8.41.0)
eslint-plugin-import: 2.26.0(@typescript-eslint/parser@5.59.8)(eslint-import-resolver-typescript@3.5.2)(eslint@8.41.0)
eslint-plugin-import: 2.26.0(eslint@8.41.0)
eslint-plugin-jsx-a11y: 6.6.1(eslint@8.41.0)
eslint-plugin-react: 7.32.2(eslint@8.41.0)
eslint-plugin-react-hooks: 4.6.0(eslint@8.41.0)
@@ -10498,7 +10497,7 @@ packages:
debug: 4.3.4
enhanced-resolve: 5.12.0
eslint: 8.41.0
eslint-plugin-import: 2.26.0(@typescript-eslint/parser@5.59.8)(eslint-import-resolver-typescript@3.5.2)(eslint@8.41.0)
eslint-plugin-import: 2.26.0(eslint@8.41.0)
get-tsconfig: 4.4.0
globby: 13.1.2
is-core-module: 2.11.0
@@ -10508,7 +10507,7 @@ packages:
- supports-color
dev: false
/eslint-module-utils@2.7.4(@typescript-eslint/parser@5.59.8)(eslint-import-resolver-node@0.3.6)(eslint-import-resolver-typescript@3.5.2)(eslint@8.41.0):
/eslint-module-utils@2.7.4(eslint-import-resolver-node@0.3.6)(eslint@8.41.0):
resolution: {integrity: sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==}
engines: {node: '>=4'}
peerDependencies:
@@ -10529,11 +10528,9 @@ packages:
eslint-import-resolver-webpack:
optional: true
dependencies:
'@typescript-eslint/parser': 5.59.8(eslint@8.41.0)(typescript@5.1.3)
debug: 3.2.7
eslint: 8.41.0
eslint-import-resolver-node: 0.3.6
eslint-import-resolver-typescript: 3.5.2(eslint-plugin-import@2.26.0)(eslint@8.41.0)
transitivePeerDependencies:
- supports-color
dev: false
@@ -10554,7 +10551,7 @@ packages:
semver: 7.3.8
dev: true
/eslint-plugin-import@2.26.0(@typescript-eslint/parser@5.59.8)(eslint-import-resolver-typescript@3.5.2)(eslint@8.41.0):
/eslint-plugin-import@2.26.0(eslint@8.41.0):
resolution: {integrity: sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==}
engines: {node: '>=4'}
peerDependencies:
@@ -10564,14 +10561,13 @@ packages:
'@typescript-eslint/parser':
optional: true
dependencies:
'@typescript-eslint/parser': 5.59.8(eslint@8.41.0)(typescript@5.1.3)
array-includes: 3.1.6
array.prototype.flat: 1.3.1
debug: 2.6.9
doctrine: 2.1.0
eslint: 8.41.0
eslint-import-resolver-node: 0.3.6
eslint-module-utils: 2.7.4(@typescript-eslint/parser@5.59.8)(eslint-import-resolver-node@0.3.6)(eslint-import-resolver-typescript@3.5.2)(eslint@8.41.0)
eslint-module-utils: 2.7.4(eslint-import-resolver-node@0.3.6)(eslint@8.41.0)
has: 1.0.3
is-core-module: 2.11.0
is-glob: 4.0.3
@@ -13154,7 +13150,7 @@ packages:
exit: 0.1.2
graceful-fs: 4.2.10
import-local: 3.1.0
jest-config: 29.5.0(@types/node@20.2.3)
jest-config: 29.5.0
jest-util: 29.5.0
jest-validate: 29.5.0
prompts: 2.4.2
@@ -13165,6 +13161,44 @@ packages:
- ts-node
dev: true
/jest-config@29.5.0:
resolution: {integrity: sha512-kvDUKBnNJPNBmFFOhDbm59iu1Fii1Q6SxyhXfvylq3UTHbg6o7j/g8k2dZyXWLvfdKB1vAPxNZnMgtKJcmu3kA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
peerDependencies:
'@types/node': '*'
ts-node: '>=9.0.0'
peerDependenciesMeta:
'@types/node':
optional: true
ts-node:
optional: true
dependencies:
'@babel/core': 7.20.12
'@jest/test-sequencer': 29.5.0
'@jest/types': 29.5.0
babel-jest: 29.5.0(@babel/core@7.20.12)
chalk: 4.1.2
ci-info: 3.7.0
deepmerge: 4.2.2
glob: 7.2.3
graceful-fs: 4.2.10
jest-circus: 29.5.0
jest-environment-node: 29.5.0
jest-get-type: 29.4.3
jest-regex-util: 29.4.3
jest-resolve: 29.5.0
jest-runner: 29.5.0
jest-util: 29.5.0
jest-validate: 29.5.0
micromatch: 4.0.5
parse-json: 5.2.0
pretty-format: 29.5.0
slash: 3.0.0
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
dev: true
/jest-config@29.5.0(@types/node@20.2.3):
resolution: {integrity: sha512-kvDUKBnNJPNBmFFOhDbm59iu1Fii1Q6SxyhXfvylq3UTHbg6o7j/g8k2dZyXWLvfdKB1vAPxNZnMgtKJcmu3kA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -16596,6 +16630,22 @@ packages:
postcss: 8.4.23
dev: true
/postcss-load-config@3.1.4:
resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==}
engines: {node: '>= 10'}
peerDependencies:
postcss: '>=8.0.9'
ts-node: '>=9.0.0'
peerDependenciesMeta:
postcss:
optional: true
ts-node:
optional: true
dependencies:
lilconfig: 2.1.0
yaml: 1.10.2
dev: true
/postcss-load-config@3.1.4(postcss@8.4.21):
resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==}
engines: {node: '>= 10'}
@@ -20392,6 +20442,41 @@ packages:
/tslib@2.5.2:
resolution: {integrity: sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA==}
/tsup@6.7.0:
resolution: {integrity: sha512-L3o8hGkaHnu5TdJns+mCqFsDBo83bJ44rlK7e6VdanIvpea4ArPcU3swWGsLVbXak1PqQx/V+SSmFPujBK+zEQ==}
engines: {node: '>=14.18'}
hasBin: true
peerDependencies:
'@swc/core': ^1
postcss: ^8.4.12
typescript: '>=4.1.0'
peerDependenciesMeta:
'@swc/core':
optional: true
postcss:
optional: true
typescript:
optional: true
dependencies:
bundle-require: 4.0.1(esbuild@0.17.11)
cac: 6.7.14
chokidar: 3.5.3
debug: 4.3.4
esbuild: 0.17.11
execa: 5.1.1
globby: 11.1.0
joycon: 3.1.1
postcss-load-config: 3.1.4
resolve-from: 5.0.0
rollup: 3.5.1
source-map: 0.8.0-beta.0
sucrase: 3.29.0
tree-kill: 1.2.2
transitivePeerDependencies:
- supports-color
- ts-node
dev: true
/tsup@6.7.0(postcss@8.4.24)(typescript@5.0.4):
resolution: {integrity: sha512-L3o8hGkaHnu5TdJns+mCqFsDBo83bJ44rlK7e6VdanIvpea4ArPcU3swWGsLVbXak1PqQx/V+SSmFPujBK+zEQ==}
engines: {node: '>=14.18'}
@@ -20453,7 +20538,7 @@ packages:
execa: 5.1.1
globby: 11.1.0
joycon: 3.1.1
postcss-load-config: 3.1.4(postcss@8.4.24)
postcss-load-config: 3.1.4
resolve-from: 5.0.0
rollup: 3.5.1
source-map: 0.8.0-beta.0
@@ -20688,6 +20773,10 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
/ua-parser-js@1.0.35:
resolution: {integrity: sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==}
dev: false
/uc.micro@1.0.6:
resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
dev: false