diff --git a/apps/app/package.json b/apps/app/package.json index 6940377dc..8279ecb2d 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -13,6 +13,7 @@ "@blueprintjs/popover2": "^1.13.3", "@headlessui/react": "^1.7.3", "@heroicons/react": "^2.0.12", + "@jitsu/nextjs": "^3.1.5", "@remirror/core": "^2.0.11", "@remirror/extension-react-tables": "^2.2.11", "@remirror/pm": "^2.0.3", diff --git a/apps/app/pages/api/track-event.ts b/apps/app/pages/api/track-event.ts new file mode 100644 index 000000000..62944e4e5 --- /dev/null +++ b/apps/app/pages/api/track-event.ts @@ -0,0 +1,50 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +// jitsu +import { createClient } from "@jitsu/nextjs"; +import { convertCookieStringToObject } from "lib/cookie"; + +const jitsu = createClient({ + key: process.env.JITSU_ACCESS_KEY || "", + tracking_host: "https://t.jitsu.com", +}); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { eventName, extra } = req.body; + + if (!eventName) { + return res.status(400).json({ message: "Bad request" }); + } + + const cookie = convertCookieStringToObject(req.headers.cookie); + const accessToken = cookie?.accessToken; + + if (!accessToken) return res.status(401).json({ message: "Unauthorized" }); + + const user = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }) + .then((res) => res.json()) + .then((data) => data.user) + .catch(() => res.status(401).json({ message: "Unauthorized" })); + + if (!user) return res.status(401).json({ message: "Unauthorized" }); + + // TODO: cache user info + + jitsu + .id({ + ...user, + }) + .then(() => { + jitsu.track(eventName, { + ...extra, + }); + }); + + res.status(200).json({ message: "success" }); +} diff --git a/apps/app/services/cycles.service.ts b/apps/app/services/cycles.service.ts index fd938a6fb..75ffe8e98 100644 --- a/apps/app/services/cycles.service.ts +++ b/apps/app/services/cycles.service.ts @@ -1,5 +1,7 @@ // services import APIService from "services/api.service"; +import trackEventServices from "services/track-event.service"; + // types import type { CycleIssueResponse, @@ -13,6 +15,9 @@ import type { const { NEXT_PUBLIC_API_BASE_URL } = process.env; +const trackEvent = + process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; + class ProjectCycleServices extends APIService { constructor() { super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); @@ -20,7 +25,10 @@ class ProjectCycleServices extends APIService { async createCycle(workspaceSlug: string, projectId: string, data: any): Promise { return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/`, data) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackCycleCreateEvent(response?.data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); @@ -86,7 +94,10 @@ class ProjectCycleServices extends APIService { `/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/`, data ) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackCycleUpdateEvent(response?.data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); @@ -102,7 +113,10 @@ class ProjectCycleServices extends APIService { `/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/`, data ) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackCycleUpdateEvent(response?.data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); @@ -110,7 +124,10 @@ class ProjectCycleServices extends APIService { async deleteCycle(workspaceSlug: string, projectId: string, cycleId: string): Promise { return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/`) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackCycleDeleteEvent(response?.data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); diff --git a/apps/app/services/issues.service.ts b/apps/app/services/issues.service.ts index cb362dc05..e69f09881 100644 --- a/apps/app/services/issues.service.ts +++ b/apps/app/services/issues.service.ts @@ -1,10 +1,14 @@ // services import APIService from "services/api.service"; +import trackEventServices from "services/track-event.service"; // type import type { IIssue, IIssueActivity, IIssueComment, IIssueViewOptions } from "types"; const { NEXT_PUBLIC_API_BASE_URL } = process.env; +const trackEvent = + process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; + class ProjectIssuesServices extends APIService { constructor() { super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); @@ -12,7 +16,10 @@ class ProjectIssuesServices extends APIService { async createIssues(workspaceSlug: string, projectId: string, data: any): Promise { return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/`, data) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackIssueCreateEvent(response.data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); @@ -241,7 +248,10 @@ class ProjectIssuesServices extends APIService { `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/`, data ) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackIssueUpdateEvent(data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); @@ -257,7 +267,10 @@ class ProjectIssuesServices extends APIService { `/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/`, data ) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackIssueUpdateEvent(data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); @@ -265,7 +278,10 @@ class ProjectIssuesServices extends APIService { async deleteIssue(workspaceSlug: string, projectId: string, issuesId: string): Promise { return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issuesId}/`) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackIssueDeleteEvent({ issuesId }); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); @@ -276,7 +292,10 @@ class ProjectIssuesServices extends APIService { `/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-delete-issues/`, data ) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackIssueBulkDeleteEvent(data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); diff --git a/apps/app/services/modules.service.ts b/apps/app/services/modules.service.ts index 29a1f02be..77b6ec329 100644 --- a/apps/app/services/modules.service.ts +++ b/apps/app/services/modules.service.ts @@ -1,10 +1,15 @@ // services import APIService from "services/api.service"; +import trackEventServices from "./track-event.service"; + // types import type { IIssueViewOptions, IModule, IIssue } from "types"; const { NEXT_PUBLIC_API_BASE_URL } = process.env; +const trackEvent = + process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; + class ProjectIssuesServices extends APIService { constructor() { super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); @@ -20,7 +25,10 @@ class ProjectIssuesServices extends APIService { async createModule(workspaceSlug: string, projectId: string, data: any): Promise { return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/`, data) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackModuleCreateEvent(response?.data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); @@ -36,7 +44,10 @@ class ProjectIssuesServices extends APIService { `/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/`, data ) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackModuleUpdateEvent(response?.data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); @@ -60,7 +71,10 @@ class ProjectIssuesServices extends APIService { `/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/`, data ) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackModuleUpdateEvent(response?.data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); @@ -70,7 +84,10 @@ class ProjectIssuesServices extends APIService { return this.delete( `/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/` ) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackModuleDeleteEvent(response?.data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); diff --git a/apps/app/services/project.service.ts b/apps/app/services/project.service.ts index ec07b7aa4..4690ffe67 100644 --- a/apps/app/services/project.service.ts +++ b/apps/app/services/project.service.ts @@ -1,5 +1,7 @@ // services import APIService from "services/api.service"; +import trackEventServices from "services/track-event.service"; + // types import type { GithubRepositoriesResponse, @@ -12,6 +14,9 @@ import type { const { NEXT_PUBLIC_API_BASE_URL } = process.env; +const trackEvent = + process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; + class ProjectServices extends APIService { constructor() { super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); @@ -19,7 +24,10 @@ class ProjectServices extends APIService { async createProject(workspaceSlug: string, data: Partial): Promise { return this.post(`/api/workspaces/${workspaceSlug}/projects/`, data) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackCreateProjectEvent(response.data); + return response?.data; + }) .catch((error) => { throw error?.response; }); @@ -59,7 +67,10 @@ class ProjectServices extends APIService { data: Partial ): Promise { return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/`, data) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackUpdateProjectEvent(response.data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); @@ -67,7 +78,10 @@ class ProjectServices extends APIService { async deleteProject(workspaceSlug: string, projectId: string): Promise { return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/`) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackDeleteProjectEvent({ projectId }); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); diff --git a/apps/app/services/state.service.ts b/apps/app/services/state.service.ts index 2940dfd1c..cdf80fe5b 100644 --- a/apps/app/services/state.service.ts +++ b/apps/app/services/state.service.ts @@ -1,8 +1,12 @@ // services import APIService from "services/api.service"; +import trackEventServices from "services/track-event.service"; const { NEXT_PUBLIC_API_BASE_URL } = process.env; +const trackEvent = + process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; + // types import type { IState, StateResponse } from "types"; @@ -13,7 +17,10 @@ class ProjectStateServices extends APIService { async createState(workspaceSlug: string, projectId: string, data: any): Promise { return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/states/`, data) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackStateCreateEvent(response?.data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); @@ -54,7 +61,10 @@ class ProjectStateServices extends APIService { `/api/workspaces/${workspaceSlug}/projects/${projectId}/states/${stateId}/`, data ) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackStateUpdateEvent(response?.data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); @@ -70,7 +80,10 @@ class ProjectStateServices extends APIService { `/api/workspaces/${workspaceSlug}/projects/${projectId}/states/${stateId}/`, data ) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackStateUpdateEvent(response?.data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); @@ -78,7 +91,10 @@ class ProjectStateServices extends APIService { async deleteState(workspaceSlug: string, projectId: string, stateId: string): Promise { return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/states/${stateId}/`) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackStateDeleteEvent(response?.data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); diff --git a/apps/app/services/track-event.service.ts b/apps/app/services/track-event.service.ts new file mode 100644 index 000000000..936e14aa5 --- /dev/null +++ b/apps/app/services/track-event.service.ts @@ -0,0 +1,314 @@ +// services +import APIService from "services/api.service"; +// types +import type { IWorkspace } from "types"; + +// TODO: as we add more events, we can refactor this to be divided into different classes +class TrackEventServices extends APIService { + constructor() { + super("/"); + } + + async trackCreateWorkspaceEvent(data: IWorkspace): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "CREATE_WORKSPACE", + extra: { + ...data, + }, + }, + }); + } + + async trackUpdateWorkspaceEvent(data: IWorkspace): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "UPDATE_WORKSPACE", + extra: { + ...data, + }, + }, + }); + } + + async trackDeleteWorkspaceEvent(data: any): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "DELETE_WORKSPACE", + extra: { + ...data, + }, + }, + }); + } + + async trackCreateProjectEvent(data: any): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "CREATE_PROJECT", + extra: { + ...data, + }, + }, + }); + } + + async trackUpdateProjectEvent(data: any): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "UPDATE_PROJECT", + extra: { + ...data, + }, + }, + }); + } + + async trackDeleteProjectEvent(data: any): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "DELETE_PROJECT", + extra: { + ...data, + }, + }, + }); + } + + async trackWorkspaceUserInviteEvent(data: any): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "WORKSPACE_USER_INVITE", + extra: { + ...data, + }, + }, + }); + } + + async trackWorkspaceUserJoinEvent(data: any): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "WORKSPACE_USER_INVITE_ACCEPT", + extra: { + ...data, + }, + }, + }); + } + + async trackWorkspaceUserBulkJoinEvent(data: any): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "WORKSPACE_USER_BULK_INVITE_ACCEPT", + extra: { + ...data, + }, + }, + }); + } + + async trackUserOnboardingCompleteEvent(data: any): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "USER_ONBOARDING_COMPLETE", + extra: { + ...data, + }, + }, + }); + } + + async trackIssueCreateEvent(data: any): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "ISSUE_CREATE", + extra: { + ...data, + }, + }, + }); + } + + async trackIssueUpdateEvent(data: any): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "ISSUE_UPDATE", + extra: { + ...data, + }, + }, + }); + } + + async trackIssueDeleteEvent(data: any): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "ISSUE_DELETE", + extra: { + ...data, + }, + }, + }); + } + + async trackIssueBulkDeleteEvent(data: any): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "ISSUE_BULK_DELETE", + extra: { + ...data, + }, + }, + }); + } + + async trackStateCreateEvent(data: any): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "STATE_CREATE", + extra: { + ...data, + }, + }, + }); + } + + async trackStateUpdateEvent(data: any): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "STATE_UPDATE", + extra: { + ...data, + }, + }, + }); + } + + async trackStateDeleteEvent(data: any): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "STATE_DELETE", + extra: { + ...data, + }, + }, + }); + } + + async trackCycleCreateEvent(data: any): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "CYCLE_CREATE", + extra: { + ...data, + }, + }, + }); + } + + async trackCycleUpdateEvent(data: any): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "CYCLE_UPDATE", + extra: { + ...data, + }, + }, + }); + } + + async trackCycleDeleteEvent(data: any): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "CYCLE_DELETE", + extra: { + ...data, + }, + }, + }); + } + + async trackModuleCreateEvent(data: any): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "MODULE_CREATE", + extra: { + ...data, + }, + }, + }); + } + + async trackModuleUpdateEvent(data: any): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "MODULE_UPDATE", + extra: { + ...data, + }, + }, + }); + } + + async trackModuleDeleteEvent(data: any): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "MODULE_DELETE", + extra: { + ...data, + }, + }, + }); + } +} + +const trackEventServices = new TrackEventServices(); + +export default trackEventServices; diff --git a/apps/app/services/user.service.ts b/apps/app/services/user.service.ts index d958d8671..c5748cf61 100644 --- a/apps/app/services/user.service.ts +++ b/apps/app/services/user.service.ts @@ -1,9 +1,14 @@ // services import APIService from "services/api.service"; +import trackEventServices from "services/track-event.service"; + import type { IUser, IUserActivity, IUserWorkspaceDashboard } from "types"; const { NEXT_PUBLIC_API_BASE_URL } = process.env; +const trackEvent = + process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; + class UserService extends APIService { constructor() { super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); @@ -44,7 +49,10 @@ class UserService extends APIService { async updateUserOnBoard(): Promise { return this.patch("/api/users/me/onboard/", { is_onboarded: true }) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackUserOnboardingCompleteEvent(response.data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); diff --git a/apps/app/services/workspace.service.ts b/apps/app/services/workspace.service.ts index e7d10d2f8..14562b1d1 100644 --- a/apps/app/services/workspace.service.ts +++ b/apps/app/services/workspace.service.ts @@ -1,5 +1,6 @@ // services import APIService from "services/api.service"; +import trackEventServices from "services/track-event.service"; const { NEXT_PUBLIC_API_BASE_URL } = process.env; @@ -14,6 +15,9 @@ import { IWorkspaceSearchResults, } from "types"; +const trackEvent = + process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; + class WorkspaceService extends APIService { constructor() { super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); @@ -37,7 +41,10 @@ class WorkspaceService extends APIService { async createWorkspace(data: Partial): Promise { return this.post("/api/workspaces/", data) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackCreateWorkspaceEvent(response.data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); @@ -45,7 +52,10 @@ class WorkspaceService extends APIService { async updateWorkspace(workspaceSlug: string, data: Partial): Promise { return this.patch(`/api/workspaces/${workspaceSlug}/`, data) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackUpdateWorkspaceEvent(response.data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); @@ -53,7 +63,10 @@ class WorkspaceService extends APIService { async deleteWorkspace(workspaceSlug: string): Promise { return this.delete(`/api/workspaces/${workspaceSlug}/`) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackDeleteWorkspaceEvent({ workspaceSlug }); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); @@ -61,7 +74,10 @@ class WorkspaceService extends APIService { async inviteWorkspace(workspaceSlug: string, data: any): Promise { return this.post(`/api/workspaces/${workspaceSlug}/invite/`, data) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackWorkspaceUserInviteEvent(response.data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); @@ -75,7 +91,10 @@ class WorkspaceService extends APIService { headers: {}, } ) - .then((response) => response?.data) + .then((response) => { + if (trackEvent) trackEventServices.trackWorkspaceUserJoinEvent(response.data); + return response?.data; + }) .catch((error) => { throw error?.response?.data; }); diff --git a/turbo.json b/turbo.json index dddfbf967..b5190bdd9 100644 --- a/turbo.json +++ b/turbo.json @@ -11,6 +11,8 @@ "NEXT_PUBLIC_ENABLE_SENTRY", "NEXT_PUBLIC_ENABLE_OAUTH", "NEXT_PUBLIC_UNSPLASH_ACCESS", + "NEXT_PUBLIC_TRACK_EVENTS", + "JITSU_ACCESS_KEY", "NEXT_PUBLIC_CRISP_ID" ], "pipeline": {