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:
@@ -330,7 +330,7 @@ export class Environment {
|
||||
public RELEASE = this.toOptionalString(process.env.RELEASE);
|
||||
|
||||
/**
|
||||
* A Google Analytics tracking ID, only v3 supported at this time.
|
||||
* A Google Analytics tracking ID, supports only v3 properties.
|
||||
*/
|
||||
@Contains("UA-")
|
||||
@IsOptional()
|
||||
|
||||
11
server/migrations/20230101144349-integration-indexes.js
Normal file
11
server/migrations/20230101144349-integration-indexes.js
Normal file
@@ -0,0 +1,11 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up (queryInterface, Sequelize) {
|
||||
await queryInterface.addIndex("integrations", ["teamId", "type", "service"]);
|
||||
},
|
||||
|
||||
async down (queryInterface, Sequelize) {
|
||||
await queryInterface.removeIndex("integrations", ["teamId", "type", "service"]);
|
||||
}
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Scopes,
|
||||
IsIn,
|
||||
} from "sequelize-typescript";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import { IntegrationType, IntegrationService } from "@shared/types";
|
||||
import type { IntegrationSettings } from "@shared/types";
|
||||
import Collection from "./Collection";
|
||||
import IntegrationAuthentication from "./IntegrationAuthentication";
|
||||
@@ -16,13 +16,9 @@ import User from "./User";
|
||||
import IdModel from "./base/IdModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
export enum IntegrationService {
|
||||
Diagrams = "diagrams",
|
||||
Slack = "slack",
|
||||
}
|
||||
|
||||
export enum UserCreatableIntegrationService {
|
||||
Diagrams = "diagrams",
|
||||
GoogleAnalytics = "google-analytics",
|
||||
}
|
||||
|
||||
@Scopes(() => ({
|
||||
@@ -40,12 +36,12 @@ export enum UserCreatableIntegrationService {
|
||||
@Fix
|
||||
class Integration<T = unknown> extends IdModel {
|
||||
@IsIn([Object.values(IntegrationType)])
|
||||
@Column
|
||||
type: string;
|
||||
@Column(DataType.STRING)
|
||||
type: IntegrationType;
|
||||
|
||||
@IsIn([Object.values(IntegrationService)])
|
||||
@Column
|
||||
service: string;
|
||||
@Column(DataType.STRING)
|
||||
service: IntegrationService;
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
settings: IntegrationSettings<T>;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
} from "sequelize-typescript";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import IdModel from "./base/IdModel";
|
||||
@@ -17,8 +18,8 @@ import Fix from "./decorators/Fix";
|
||||
@Table({ tableName: "authentications", modelName: "authentication" })
|
||||
@Fix
|
||||
class IntegrationAuthentication extends IdModel {
|
||||
@Column
|
||||
service: string;
|
||||
@Column(DataType.STRING)
|
||||
service: IntegrationService;
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
scopes: string[];
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { PublicEnv } from "@shared/types";
|
||||
import { IntegrationType, PublicEnv } from "@shared/types";
|
||||
import { Environment } from "@server/env";
|
||||
import { Integration } from "@server/models";
|
||||
|
||||
// Note: This entire object is stringified in the HTML exposed to the client
|
||||
// do not add anything here that should be a secret or password
|
||||
export default function present(env: Environment): PublicEnv {
|
||||
export default function present(
|
||||
env: Environment,
|
||||
analytics?: Integration<IntegrationType.Analytics> | null
|
||||
): PublicEnv {
|
||||
return {
|
||||
URL: env.URL.replace(/\/$/, ""),
|
||||
AWS_S3_UPLOAD_BUCKET_URL: process.env.AWS_S3_UPLOAD_BUCKET_URL || "",
|
||||
@@ -26,5 +30,9 @@ export default function present(env: Environment): PublicEnv {
|
||||
GOOGLE_ANALYTICS_ID: env.GOOGLE_ANALYTICS_ID,
|
||||
RELEASE:
|
||||
process.env.SOURCE_COMMIT || process.env.SOURCE_VERSION || undefined,
|
||||
analytics: {
|
||||
service: analytics?.service,
|
||||
settings: analytics?.settings,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fetch from "fetch-with-proxy";
|
||||
import { Op } from "sequelize";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import env from "@server/env";
|
||||
import { Document, Integration, Collection, Team } from "@server/models";
|
||||
import { presentSlackAttachment } from "@server/presenters";
|
||||
@@ -36,8 +36,8 @@ export default class SlackProcessor extends BaseProcessor {
|
||||
const integration = (await Integration.findOne({
|
||||
where: {
|
||||
id: event.modelId,
|
||||
service: "slack",
|
||||
type: "post",
|
||||
service: IntegrationService.Slack,
|
||||
type: IntegrationType.Post,
|
||||
},
|
||||
include: [
|
||||
{
|
||||
@@ -98,8 +98,8 @@ export default class SlackProcessor extends BaseProcessor {
|
||||
where: {
|
||||
teamId: document.teamId,
|
||||
collectionId: document.collectionId,
|
||||
service: "slack",
|
||||
type: "post",
|
||||
service: IntegrationService.Slack,
|
||||
type: IntegrationType.Post,
|
||||
events: {
|
||||
[Op.contains]: [
|
||||
event.name === "revisions.create" ? "documents.update" : event.name,
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -23,6 +23,7 @@ const scriptSrc = [
|
||||
"'unsafe-inline'",
|
||||
"'unsafe-eval'",
|
||||
"gist.github.com",
|
||||
"www.googletagmanager.com",
|
||||
"cdn.zapier.com",
|
||||
];
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
CollectionPermission,
|
||||
FileOperationState,
|
||||
FileOperationType,
|
||||
IntegrationService,
|
||||
IntegrationType,
|
||||
} from "@shared/types";
|
||||
import {
|
||||
Share,
|
||||
@@ -241,15 +243,15 @@ export async function buildIntegration(overrides: Partial<Integration> = {}) {
|
||||
teamId: overrides.teamId,
|
||||
});
|
||||
const authentication = await IntegrationAuthentication.create({
|
||||
service: "slack",
|
||||
service: IntegrationService.Slack,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
token: "fake-access-token",
|
||||
scopes: ["example", "scopes", "here"],
|
||||
});
|
||||
return Integration.create({
|
||||
type: "post",
|
||||
service: "slack",
|
||||
service: IntegrationService.Slack,
|
||||
type: IntegrationType.Post,
|
||||
events: ["documents.update", "documents.publish"],
|
||||
settings: {
|
||||
serviceTeamId: "slack_team_id",
|
||||
|
||||
@@ -89,7 +89,7 @@ export function assertUrl(
|
||||
require_valid_protocol: true,
|
||||
})
|
||||
) {
|
||||
throw ValidationError(message ?? `${String(value)} is an invalid url!`);
|
||||
throw ValidationError(message ?? `${String(value)} is an invalid url`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,9 +105,7 @@ export function assertBoolean(
|
||||
message?: string
|
||||
): asserts value {
|
||||
if (typeof value !== "boolean") {
|
||||
throw ValidationError(
|
||||
message ?? `${String(value)} is a ${typeof value}, not a boolean!`
|
||||
);
|
||||
throw ValidationError(message ?? `${String(value)} is not a boolean`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user