feat: Add GA integration, support for GA4 (#4626)

* GA integration settings

* trackingId -> measurementId
Hook up script

* Public page GA tracking
Correct layout of settings

* Remove multiple codepaths for loading GA measurementID, add missing db index

* Remove unneccessary changes, tsc

* test
This commit is contained in:
Tom Moor
2023-01-01 15:29:08 +00:00
committed by GitHub
parent dc795604a4
commit 8e4270c321
29 changed files with 374 additions and 114 deletions

View File

@@ -1,3 +1,4 @@
import { IntegrationService } from "@shared/types";
import env from "@server/env";
import { IntegrationAuthentication, SearchQuery } from "@server/models";
import { buildDocument, buildIntegration } from "@server/test/factories";
@@ -14,7 +15,7 @@ describe("#hooks.unfurl", () => {
it("should return documents", async () => {
const { user, document } = await seed();
await IntegrationAuthentication.create({
service: "slack",
service: IntegrationService.Slack,
userId: user.id,
teamId: user.teamId,
token: "",

View File

@@ -1,6 +1,7 @@
import crypto from "crypto";
import Router from "koa-router";
import { escapeRegExp } from "lodash";
import { IntegrationService } from "@shared/types";
import env from "@server/env";
import { AuthenticationError, InvalidRequestError } from "@server/errors";
import Logger from "@server/logging/Logger";
@@ -67,7 +68,7 @@ router.post("hooks.unfurl", async (ctx) => {
}
const auth = await IntegrationAuthentication.findOne({
where: {
service: "slack",
service: IntegrationService.Slack,
teamId: user.teamId,
},
});
@@ -240,7 +241,7 @@ router.post("hooks.slack", async (ctx) => {
if (!user) {
const auth = await IntegrationAuthentication.findOne({
where: {
service: "slack",
service: IntegrationService.Slack,
teamId: team.id,
},
});

View File

@@ -1,5 +1,6 @@
import Router from "koa-router";
import { has } from "lodash";
import { WhereOptions } from "sequelize";
import { IntegrationType } from "@shared/types";
import auth from "@server/middlewares/authentication";
import { Event } from "@server/models";
@@ -21,17 +22,27 @@ const router = new Router();
router.post("integrations.list", auth(), pagination(), async (ctx) => {
let { direction } = ctx.request.body;
const { sort = "updatedAt" } = ctx.request.body;
const { user } = ctx.state;
const { type, sort = "updatedAt" } = ctx.request.body;
if (direction !== "ASC") {
direction = "DESC";
}
assertSort(sort, Integration);
const { user } = ctx.state;
let where: WhereOptions<Integration> = {
teamId: user.teamId,
};
if (type) {
assertIn(type, Object.values(IntegrationType));
where = {
...where,
type,
};
}
const integrations = await Integration.findAll({
where: {
teamId: user.teamId,
},
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,

View File

@@ -5,8 +5,10 @@ import { Context, Next } from "koa";
import { escape } from "lodash";
import { Sequelize } from "sequelize";
import isUUID from "validator/lib/isUUID";
import { IntegrationType } from "@shared/types";
import documentLoader from "@server/commands/documentLoader";
import env from "@server/env";
import { Integration } from "@server/models";
import presentEnv from "@server/presenters/env";
import { getTeamFromContext } from "@server/utils/passport";
import prefetchTags from "@server/utils/prefetchTags";
@@ -54,7 +56,12 @@ const readIndexFile = async (ctx: Context): Promise<Buffer> => {
export const renderApp = async (
ctx: Context,
next: Next,
options: { title?: string; description?: string; canonical?: string } = {}
options: {
title?: string;
description?: string;
canonical?: string;
analytics?: Integration | null;
} = {}
) => {
const {
title = "Outline",
@@ -69,7 +76,7 @@ export const renderApp = async (
const { shareId } = ctx.params;
const page = await readIndexFile(ctx);
const environment = `
window.env = ${JSON.stringify(presentEnv(env))};
window.env = ${JSON.stringify(presentEnv(env, options.analytics))};
`;
ctx.body = page
.toString()
@@ -86,7 +93,7 @@ export const renderShare = async (ctx: Context, next: Next) => {
// Find the share record if publicly published so that the document title
// can be be returned in the server-rendered HTML. This allows it to appear in
// unfurls with more reliablity
let share, document;
let share, document, analytics;
try {
const team = await getTeamFromContext(ctx);
@@ -105,6 +112,13 @@ export const renderShare = async (ctx: Context, next: Next) => {
}
document = result.document;
analytics = await Integration.findOne({
where: {
teamId: document.teamId,
type: IntegrationType.Analytics,
},
});
if (share && !ctx.userAgent.isBot) {
await share.update({
lastAccessedAt: new Date(),
@@ -123,6 +137,7 @@ export const renderShare = async (ctx: Context, next: Next) => {
return renderApp(ctx, next, {
title: document?.title,
description: document?.getSummary(),
analytics,
canonical: share
? `${share.canonicalUrl}${documentSlug && document ? document.url : ""}`
: undefined,

View File

@@ -3,6 +3,7 @@ import type { Context } from "koa";
import Router from "koa-router";
import { Profile } from "passport";
import { Strategy as SlackStrategy } from "passport-slack-oauth2";
import { IntegrationService, IntegrationType } from "@shared/types";
import accountProvisioner from "@server/commands/accountProvisioner";
import env from "@server/env";
import auth from "@server/middlewares/authentication";
@@ -173,15 +174,15 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
const endpoint = `${env.URL}/auth/slack.commands`;
const data = await Slack.oauthAccess(String(code), endpoint);
const authentication = await IntegrationAuthentication.create({
service: "slack",
service: IntegrationService.Slack,
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(","),
});
await Integration.create({
service: "slack",
type: "command",
service: IntegrationService.Slack,
type: IntegrationType.Command,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
@@ -239,7 +240,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
const endpoint = `${env.URL}/auth/slack.post`;
const data = await Slack.oauthAccess(code as string, endpoint);
const authentication = await IntegrationAuthentication.create({
service: "slack",
service: IntegrationService.Slack,
userId: user.id,
teamId: user.teamId,
token: data.access_token,
@@ -247,8 +248,8 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
});
await Integration.create({
service: "slack",
type: "post",
service: IntegrationService.Slack,
type: IntegrationType.Post,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,

View File

@@ -5,9 +5,12 @@ import Router from "koa-router";
import send from "koa-send";
import userAgent, { UserAgentContext } from "koa-useragent";
import { languages } from "@shared/i18n";
import { IntegrationType } from "@shared/types";
import env from "@server/env";
import { NotFoundError } from "@server/errors";
import { Integration } from "@server/models";
import { opensearchResponse } from "@server/utils/opensearch";
import { getTeamFromContext } from "@server/utils/passport";
import { robotsResponse } from "@server/utils/robots";
import apexRedirect from "../middlewares/apexRedirect";
import { renderApp, renderShare } from "./app";
@@ -118,7 +121,21 @@ router.get("/s/:shareId/doc/:documentSlug", renderShare);
router.get("/s/:shareId/*", renderShare);
// catch all for application
router.get("*", renderApp);
router.get("*", async (ctx, next) => {
const team = await getTeamFromContext(ctx);
const analytics = team
? await Integration.findOne({
where: {
teamId: team.id,
type: IntegrationType.Analytics,
},
})
: undefined;
return renderApp(ctx, next, {
analytics,
});
});
// In order to report all possible performance metrics to Sentry this header
// must be provided when serving the application, see: