Add Posthog sync endpoints (#217)

* Add new API endpoints to sync events and persons with Posthog
This commit is contained in:
Matti Nannt
2023-04-05 13:20:55 +02:00
committed by GitHub
parent 7802e1fbca
commit 46e6a32a3a
11 changed files with 508 additions and 63 deletions
@@ -0,0 +1,121 @@
import { getSessionOrUser, hasEnvironmentAccess } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
// Check Authentication
const user: any = await getSessionOrUser(req, res);
if (!user) {
return res.status(401).json({ message: "Not authenticated" });
}
const environmentId = req.query?.environmentId?.toString();
const hasAccess = await hasEnvironmentAccess(user, environmentId);
if (hasAccess === false) {
return res.status(403).json({ message: "Not authorized" });
}
// POST
if (req.method === "POST") {
// lastSyncedAt is the last time the environment was synced (iso string)
const { lastSyncedAt } = req.body;
let lastSyncedCondition = lastSyncedAt
? {
OR: [
{
createdAt: {
gt: lastSyncedAt,
},
},
{
updatedAt: {
gt: lastSyncedAt,
},
},
],
}
: {};
// Get all displays that have been created or updated since lastSyncedAt
const displays = await prisma.display.findMany({
where: {
survey: {
environmentId,
},
...lastSyncedCondition,
},
select: {
id: true,
createdAt: true,
updatedAt: true,
person: {
select: {
attributes: {
select: {
id: true,
value: true,
attributeClass: {
select: {
name: true,
},
},
},
},
},
},
},
});
// Get all responses that have been created or updated since lastSyncedAt
const responses = await prisma.response.findMany({
where: {
survey: {
environmentId,
},
...lastSyncedCondition,
},
select: {
id: true,
createdAt: true,
updatedAt: true,
person: {
select: {
attributes: {
select: {
id: true,
value: true,
attributeClass: {
select: {
name: true,
},
},
},
},
},
},
},
});
const events = [
...displays.map((display) => ({
name: "formbricks_display_created",
timestamp: display.createdAt,
userId: display.person?.attributes?.find((attr) => attr.attributeClass.name === "userId")?.value,
})),
...responses.map((response) => ({
name: "formbricks_response_created",
timestamp: response.createdAt,
userId: response.person?.attributes?.find((attr) => attr.attributeClass.name === "userId")?.value,
})),
];
return res.json({ events, lastSyncedAt: new Date().toISOString() });
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}
@@ -0,0 +1,162 @@
import { getSessionOrUser, hasEnvironmentAccess } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database";
import type { NextApiRequest, NextApiResponse } from "next";
interface FormbricksUser {
userId: string;
attributes: { [key: string]: string };
}
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
// Check Authentication
const user: any = await getSessionOrUser(req, res);
if (!user) {
return res.status(401).json({ message: "Not authenticated" });
}
const environmentId = req.query?.environmentId?.toString();
if (!environmentId) {
return res.status(400).json({ message: "Missing environmentId" });
}
const hasAccess = await hasEnvironmentAccess(user, environmentId);
if (hasAccess === false) {
return res.status(403).json({ message: "Not authorized" });
}
// POST
if (req.method === "POST") {
// lastSyncedAt is the last time the environment was synced (iso string)
const { users }: { users: FormbricksUser[] } = req.body;
for (const user of users) {
// check if user with this userId as attribute already exists
const existingUser = await prisma.person.findFirst({
where: {
attributes: {
some: {
attributeClass: {
name: "userId",
environmentId,
},
value: user.userId,
},
},
},
select: {
id: true,
attributes: {
select: {
id: true,
value: true,
attributeClass: {
select: {
name: true,
},
},
},
},
},
});
if (!existingUser) {
const attributeType: "noCode" = "noCode";
// create user with this attributes (create or connect attribute with the same attributeClass name)
await prisma.person.create({
data: {
attributes: {
create: Object.keys(user.attributes).map((key) => ({
value: user.attributes[key],
attributeClass: {
connectOrCreate: {
where: {
name_environmentId: {
name: key,
environmentId,
},
},
create: {
name: key,
type: attributeType,
environment: {
connect: {
id: environmentId,
},
},
},
},
},
})),
},
environment: {
connect: {
id: environmentId,
},
},
},
});
} else {
// user already exists, loop through attributes and update or create them
const attributeType: "noCode" = "noCode";
for (const key of Object.keys(user.attributes)) {
const existingAttribute = existingUser.attributes.find(
(attribute) => attribute.attributeClass.name === key
);
if (existingAttribute) {
// skip if value is the same
if (existingAttribute.value === user.attributes[key]) {
continue;
}
await prisma.attribute.update({
where: {
id: existingAttribute.id,
},
data: {
value: user.attributes[key],
},
});
} else {
// create attribute
await prisma.attribute.create({
data: {
value: user.attributes[key],
attributeClass: {
connectOrCreate: {
where: {
name_environmentId: {
name: key,
environmentId,
},
},
create: {
name: key,
type: attributeType,
environment: {
connect: {
id: environmentId,
},
},
},
},
},
person: {
connect: {
id: existingUser.id,
},
},
},
});
}
}
}
}
return res.status(200).end();
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ datasource db {
generator client {
provider = "prisma-client-js"
previewFeatures = ["filteredRelationCount"]
previewFeatures = ["filteredRelationCount", "extendedWhereUnique"]
//provider = "prisma-dbml-generator"
}
+1 -3
View File
@@ -20,10 +20,8 @@ export const renderWidget = (survey: Survey) => {
};
export const closeSurvey = async (): Promise<void> => {
console.log("close survey called");
// remove container element from DOM
const container = document.getElementById(containerId);
container.remove();
document.getElementById(containerId).remove();
addWidgetContainer();
const settings = await getSettings();
config.update({ settings });
@@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: ["formbricks"],
};
@@ -1,22 +0,0 @@
// <TODO: your plugin code here - you can base it on the code below, but you don't have to>
// Some internal library function
async function getRandomNumber() {
return 4;
}
// Plugin method that runs on plugin load
export async function setupPlugin({ config }) {
console.log(`Setting up the plugin`);
}
// Plugin method that processes event
export async function processEvent(event, { config, cache }) {
const counterValue = await cache.get("greeting_counter", 0);
cache.set("greeting_counter", counterValue + 1);
if (!event.properties) event.properties = {};
event.properties["greeting"] = config.greeting;
event.properties["greeting_counter"] = counterValue;
event.properties["random_number"] = await getRandomNumber();
return event;
}
@@ -0,0 +1,76 @@
interface FormbricksUser {
userId: string;
attributes: { [key: string]: any };
}
export async function setupPlugin({ storage, config, global }) {
if (!config.formbricksHost || !config.environmentId || config.apiKey) {
throw new Error("Please set the 'formbricksHost', 'environmentId' & 'apiKey' config values");
}
const resetStorage = config.resetStorage === "Yes";
if (resetStorage) {
await storage.del("formbricks-lastSyncedAt");
}
if (!global.projectId) {
throw new Error(`Could not get ID for Github project: ${config.user}/${config.repo}`);
}
}
export async function runEveryHour({ cache, storage, global, config }) {
let lastSyncedAt = await storage.get("formbricks-lastSyncedAt", null);
if (config.import === "Yes") {
const response = await fetch(
`${config.formbricksHost}/api/v1/environemnts/${config.environmentId}/posthog/export`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Api-Key": config.apiKey,
},
body: JSON.stringify({ lastSyncedAt }),
}
);
const result = await response.json();
for (const event of result.events) {
posthog.capture(event.name, {
timestamp: event.timestamp,
userId: event.userId,
});
}
}
if (config.export === "Yes") {
const userRes = await posthog.api.get("/api/projects/@current/persons", {
host: global.posthogUrl,
personalApiKey: global.posthogApiKey,
projectApiKey: global.posthogProjectKey,
});
const userResponse = await userRes.json();
const users: FormbricksUser[] = [];
if (userResponse.results && userResponse.results.length > 0) {
for (const loadedUser of userResponse["results"]) {
for (const distinctId of loadedUser["distinct_ids"]) {
users.push({
userId: distinctId,
attributes: loadedUser["properties"],
});
}
}
}
await fetch(`${config.formbricksHost}/api/v1/environemnts/${config.environmentId}/posthog/import`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Api-Key": config.apiKey,
},
body: JSON.stringify({ users }),
});
}
await storage.set("formbricks-lastSyncedAt", new Date().toISOString());
}
@@ -0,0 +1,22 @@
{
"name": "@formbricks/posthog-formbricks-plugin",
"private": true,
"sideEffects": false,
"version": "0.0.0",
"main": "./index.ts",
"types": "./index.ts",
"license": "MIT",
"scripts": {
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json"
},
"devDependencies": {
"@formbricks/tsconfig": "*",
"@formbricks/types": "*",
"@posthog/plugin-scaffold": "^1.4.2",
"eslint": "^8.27.0",
"eslint-config-formbricks": "workspace:*",
"typescript": "^4.9.4"
}
}
+36 -9
View File
@@ -2,24 +2,51 @@
"name": "Formbricks",
"config": [
{
"markdown": "## MANUAL STEP NOTICE: This app needs to injects code into your website through posthog-js. You need to **opt-in** on your site to enable this behaviour.\n\n```\nposthog.init(\"api_key\", {\n \"api_host\": \"https://app.posthog.com\",\n \"opt_in_site_apps\": true,\n})\n```"
"key": "formbricksHost",
"name": "Formbricks Host",
"type": "string",
"default": "https://app.formbricks.com",
"required": true
},
{
"key": "environmentId",
"name": "Environment ID",
"type": "string",
"hint": "Can be found in the Setup Checklist in the Formbricks Settings",
"default": "clfwl2u460003qo0hnmw4lihk",
"required": true,
"site": true
"required": true
},
{
"key": "formbricksHost",
"name": "Formbricks Host",
"key": "formbricksApiKey",
"name": "Formbricks API Key",
"type": "string",
"default": "https://app.formbricks.com",
"required": true,
"site": true
"secret": true,
"hint": "You can generate a new API Key in the Formbricks Settings",
"required": true
},
{
"key": "import",
"name": "Enable Import",
"type": "choice",
"hint": "Do you want to import display events and responses from Formbricks?",
"default": "Yes",
"choices": ["Yes", "No"]
},
{
"key": "export",
"name": "Enable Export",
"type": "choice",
"hint": "Do you want to send Posthog users and their attributes to Formbricks?",
"default": "Yes",
"choices": ["Yes", "No"]
},
{
"key": "resetStorage",
"name": "Reset",
"type": "choice",
"choices": ["No", "Yes"],
"hint": "**Advanced** - Reset the plugin's storage. This will catch up all events from the beginning of time.",
"required": false,
"default": "No"
}
]
}
@@ -1,28 +0,0 @@
export function inject({ config, posthog }) {
const shadow = createShadow();
const formbricksScript = document.createElement("script");
formbricksScript.type = "text/javascript";
formbricksScript.async = true;
formbricksScript.src = "https://unpkg.com/@formbricks/js@^0.1.4/dist/index.umd.js";
shadow.appendChild(formbricksScript);
formbricksScript.onload = () => {
console.log("initializing");
setTimeout(() => {
window.formbricks = window.js;
window.formbricks.init({
environmentId: config.environmentId,
apiHost: config.formbricksHost,
logLevel: "debug",
});
}, 500);
};
}
function createShadow(): ShadowRoot {
const div = document.createElement("div");
const shadow = div.attachShadow({ mode: "open" });
document.body.appendChild(div);
return shadow;
}
+85
View File
@@ -491,6 +491,27 @@ importers:
specifier: ^4.9.4
version: 4.9.5
packages/posthog-formbricks-plugin:
devDependencies:
'@formbricks/tsconfig':
specifier: '*'
version: link:../tsconfig
'@formbricks/types':
specifier: '*'
version: link:../types
'@posthog/plugin-scaffold':
specifier: ^1.4.2
version: 1.4.2
eslint:
specifier: ^8.27.0
version: 8.37.0
eslint-config-formbricks:
specifier: workspace:*
version: link:../eslint-config-formbricks
typescript:
specifier: ^4.9.4
version: 4.9.5
packages/prettier-config:
devDependencies:
prettier:
@@ -3034,6 +3055,14 @@ packages:
unist-util-visit: 2.0.3
dev: false
/@maxmind/geoip2-node@3.5.0:
resolution: {integrity: sha512-WG2TNxMwDWDOrljLwyZf5bwiEYubaHuICvQRlgz74lE9OZA/z4o+ZT6OisjDBAZh/yRJVNK6mfHqmP5lLlAwsA==}
dependencies:
camelcase-keys: 7.0.2
ip6addr: 0.2.5
maxmind: 4.3.10
dev: true
/@mdn/browser-compat-data@3.3.14:
resolution: {integrity: sha512-n2RC9d6XatVbWFdHLimzzUJxJ1KY8LdjqrW6YvGPiRmsHkhOUx74/Ct10x5Yo7bC/Jvqx7cDEW8IMPv/+vwEzA==}
dev: true
@@ -3344,6 +3373,12 @@ packages:
resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==}
dev: true
/@posthog/plugin-scaffold@1.4.2:
resolution: {integrity: sha512-/VsRg3CfhQvYhxM2O9+gBOzj4K1QJZClY+yple0npL1Jd2nRn2nT4z7dlPSidTPZvdpFs0+hrnF+m4Kxf1NFvQ==}
dependencies:
'@maxmind/geoip2-node': 3.5.0
dev: true
/@preact/async-loader@3.0.2(preact@10.13.2):
resolution: {integrity: sha512-nYIdlAGbZ0+0/u5VJxQdLDgNFgEJmNLzctuqnCBZxW/EpLPMg8lcsnRsoXVl+O28ZZYhVhos3XiWM3KtuN0C3Q==}
engines: {node: '>=8'}
@@ -6425,6 +6460,16 @@ packages:
quick-lru: 4.0.1
dev: true
/camelcase-keys@7.0.2:
resolution: {integrity: sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==}
engines: {node: '>=12'}
dependencies:
camelcase: 6.3.0
map-obj: 4.3.0
quick-lru: 5.1.1
type-fest: 1.4.0
dev: true
/camelcase@4.1.0:
resolution: {integrity: sha512-FxAv7HpHrXbh3aPo4o2qxHay2lkLY3x5Mw3KeE4KQE8ysVfziWeRZDwcjauvwBSGEC/nXUPzZy8zeh4HokqOnw==}
engines: {node: '>=4'}
@@ -10488,6 +10533,13 @@ packages:
loose-envify: 1.4.0
dev: false
/ip6addr@0.2.5:
resolution: {integrity: sha512-9RGGSB6Zc9Ox5DpDGFnJdIeF0AsqXzdH+FspCfPPaU/L/4tI6P+5lIoFUFm9JXs9IrJv1boqAaNCQmoDADTSKQ==}
dependencies:
assert-plus: 1.0.0
jsprim: 2.0.2
dev: true
/ip@1.1.8:
resolution: {integrity: sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==}
dev: true
@@ -11757,6 +11809,16 @@ packages:
verror: 1.10.0
dev: true
/jsprim@2.0.2:
resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==}
engines: {'0': node >=0.6.0}
dependencies:
assert-plus: 1.0.0
extsprintf: 1.3.0
json-schema: 0.4.0
verror: 1.10.0
dev: true
/jsx-ast-utils@3.3.3:
resolution: {integrity: sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw==}
engines: {node: '>=4.0'}
@@ -12192,6 +12254,14 @@ packages:
pretty-bytes: 3.0.1
dev: true
/maxmind@4.3.10:
resolution: {integrity: sha512-H83pPwi4OqpjPmvAVtuimVWFe6JwHdFK+UIzq4KdvQrKUMLieIrsvU/A9N8jbmOqC2JJPA+jtlFwodyqmzl/3w==}
engines: {node: '>=12', npm: '>=6'}
dependencies:
mmdb-lib: 2.0.2
tiny-lru: 10.4.1
dev: true
/md5.js@1.3.5:
resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==}
dependencies:
@@ -13060,6 +13130,11 @@ packages:
hasBin: true
dev: true
/mmdb-lib@2.0.2:
resolution: {integrity: sha512-shi1I+fCPQonhTi7qyb6hr7hi87R7YS69FlfJiMFuJ12+grx0JyL56gLNzGTYXPU7EhAPkMLliGeyHer0K+AVA==}
engines: {node: '>=10', npm: '>=6'}
dev: true
/moo@0.5.2:
resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==}
dev: true
@@ -17617,6 +17692,11 @@ packages:
resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==}
dev: false
/tiny-lru@10.4.1:
resolution: {integrity: sha512-buLIzw7ppqymuO3pt10jHk/6QMeZLbidihMQU+N6sogF6EnBzG0qtDWIHuhw1x3dyNgVL/KTGIZsTK81+yCzLg==}
engines: {node: '>=12'}
dev: true
/tmp@0.0.33:
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
engines: {node: '>=0.6.0'}
@@ -17969,6 +18049,11 @@ packages:
resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==}
engines: {node: '>=8'}
/type-fest@1.4.0:
resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==}
engines: {node: '>=10'}
dev: true
/type-is@1.6.18:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}