From 4de4f20b46fd05cba8dc76367c3bfd4b982a4743 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Fri, 21 Oct 2022 15:04:46 +0200 Subject: [PATCH] feat(medusa): add analytics config (#2442) **What** - Adds new entity AnalyticsConfig - Adds new service AnalyticsConfigService - Adds new repository AnalyticsConfigRepository - Adds new endpoints to get, create, update, and delete analytics configs **Why** As we begin gathering usage insights to help us improve Medusa, we want to give each individual users the ability to control what data they share with us, or not share any data with us at all. The AnalyticsConfig holds information that is used to check if the user wishes for their data to be anonymized or if they have opted out of sharing usage data. The entire feature can be disabled on a store level by setting the feature flag `MEDUSA_FF_ANALYTICS=false` in their environment variables, the feature is enabled by default. **Testing** Adds integration test for each of the new endpoints Resolves CORE-656, CORE-655, CORE-654 Also resolves CORE-574 --- .../__snapshots__/analytics-config.js.snap | 57 +++++ .../admin/__snapshots__/user.js.snap | 14 ++ .../api/__tests__/admin/analytics-config.js | 205 ++++++++++++++++++ integration-tests/api/__tests__/admin/user.js | 133 +++++++++--- .../api/__tests__/batch-jobs/api.js | 5 +- .../__tests__/batch-jobs/price-list/import.js | 27 ++- .../batch-jobs/product/ff-sales-channel.js | 22 ++ .../__tests__/batch-jobs/product/import.js | 22 ++ ...-ss.csv => product-import-ss-template.csv} | 0 ...import.csv => product-import-template.csv} | 0 .../api/__tests__/claims/index.js | 2 +- .../simple-analytics-config-factory.ts | 25 +++ packages/medusa/src/api/index.js | 1 + .../create-analytics-config.ts | 33 +++ .../delete-analytics-config.ts | 23 ++ .../analytics-configs/get-analytics-config.ts | 14 ++ .../routes/admin/analytics-configs/index.ts | 43 ++++ .../update-analytics-config.ts | 35 +++ packages/medusa/src/api/routes/admin/index.js | 2 + .../src/loaders/feature-flags/analytics.ts | 11 + .../1666173221888-add_analytics_config.ts | 24 ++ .../medusa/src/models/analytics-config.ts | 23 ++ packages/medusa/src/models/index.ts | 1 + .../src/repositories/analytics-config.ts | 5 + .../medusa/src/services/analytics-config.ts | 119 ++++++++++ packages/medusa/src/services/index.ts | 1 + packages/medusa/src/services/user.ts | 26 ++- packages/medusa/src/types/analytics-config.ts | 9 + 28 files changed, 842 insertions(+), 40 deletions(-) create mode 100644 integration-tests/api/__tests__/admin/__snapshots__/analytics-config.js.snap create mode 100644 integration-tests/api/__tests__/admin/analytics-config.js rename integration-tests/api/__tests__/batch-jobs/product/{product-import-ss.csv => product-import-ss-template.csv} (100%) rename integration-tests/api/__tests__/batch-jobs/product/{product-import.csv => product-import-template.csv} (100%) create mode 100644 integration-tests/api/factories/simple-analytics-config-factory.ts create mode 100644 packages/medusa/src/api/routes/admin/analytics-configs/create-analytics-config.ts create mode 100644 packages/medusa/src/api/routes/admin/analytics-configs/delete-analytics-config.ts create mode 100644 packages/medusa/src/api/routes/admin/analytics-configs/get-analytics-config.ts create mode 100644 packages/medusa/src/api/routes/admin/analytics-configs/index.ts create mode 100644 packages/medusa/src/api/routes/admin/analytics-configs/update-analytics-config.ts create mode 100644 packages/medusa/src/loaders/feature-flags/analytics.ts create mode 100644 packages/medusa/src/migrations/1666173221888-add_analytics_config.ts create mode 100644 packages/medusa/src/models/analytics-config.ts create mode 100644 packages/medusa/src/repositories/analytics-config.ts create mode 100644 packages/medusa/src/services/analytics-config.ts create mode 100644 packages/medusa/src/types/analytics-config.ts diff --git a/integration-tests/api/__tests__/admin/__snapshots__/analytics-config.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/analytics-config.js.snap new file mode 100644 index 0000000000..52e2df7bb6 --- /dev/null +++ b/integration-tests/api/__tests__/admin/__snapshots__/analytics-config.js.snap @@ -0,0 +1,57 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`[MEDUSA_FF_ANALYTICS] /admin/analytics-config GET /admin/analytics-config should retrieve config for logged in user if it exists 1`] = ` +Object { + "analytics_config": Object { + "anonymize": false, + "created_at": Any, + "deleted_at": null, + "id": Any, + "opt_out": false, + "updated_at": Any, + "user_id": "admin_user", + }, +} +`; + +exports[`[MEDUSA_FF_ANALYTICS] /admin/analytics-config POST /admin/analytics-config should create a new config for logged in user 1`] = ` +Object { + "analytics_config": Object { + "anonymize": true, + "created_at": Any, + "deleted_at": null, + "id": Any, + "opt_out": true, + "updated_at": Any, + "user_id": "admin_user", + }, +} +`; + +exports[`[MEDUSA_FF_ANALYTICS] /admin/analytics-config POST /admin/analytics-config/update should create a config for the user is no config exists 1`] = ` +Object { + "analytics_config": Object { + "anonymize": false, + "created_at": Any, + "deleted_at": null, + "id": Any, + "opt_out": true, + "updated_at": Any, + "user_id": "admin_user", + }, +} +`; + +exports[`[MEDUSA_FF_ANALYTICS] /admin/analytics-config POST /admin/analytics-config/update should update the config of the logged in user 1`] = ` +Object { + "analytics_config": Object { + "anonymize": false, + "created_at": Any, + "deleted_at": null, + "id": Any, + "opt_out": true, + "updated_at": Any, + "user_id": "admin_user", + }, +} +`; diff --git a/integration-tests/api/__tests__/admin/__snapshots__/user.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/user.js.snap index d476d4bd01..f960505d33 100644 --- a/integration-tests/api/__tests__/admin/__snapshots__/user.js.snap +++ b/integration-tests/api/__tests__/admin/__snapshots__/user.js.snap @@ -74,3 +74,17 @@ Object { "updated_at": Any, } `; + +exports[`[MEDUSA_FF_ANALYTICS] /admin/analytics-config DELETE /admin/users Deletes a user and their analytics config 1`] = ` +Array [ + Object { + "anonymize": false, + "created_at": Any, + "deleted_at": Any, + "id": Any, + "opt_out": false, + "updated_at": Any, + "user_id": "member-user", + }, +] +`; diff --git a/integration-tests/api/__tests__/admin/analytics-config.js b/integration-tests/api/__tests__/admin/analytics-config.js new file mode 100644 index 0000000000..6d06b6df88 --- /dev/null +++ b/integration-tests/api/__tests__/admin/analytics-config.js @@ -0,0 +1,205 @@ +const path = require("path") +const startServerWithEnvironment = + require("../../../helpers/start-server-with-environment").default +const { useApi } = require("../../../helpers/use-api") +const { useDb } = require("../../../helpers/use-db") +const { + simpleAnalyticsConfigFactory, +} = require("../../factories/simple-analytics-config-factory") +const adminSeeder = require("../../helpers/admin-seeder") + +const adminReqConfig = { + headers: { + Authorization: "Bearer test_token", + }, +} + +jest.setTimeout(30000) +describe("[MEDUSA_FF_ANALYTICS] /admin/analytics-config", () => { + let medusaProcess + let dbConnection + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + const [process, connection] = await startServerWithEnvironment({ + cwd, + env: { MEDUSA_FF_ANALYTICS: true }, + }) + dbConnection = connection + medusaProcess = process + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + + medusaProcess.kill() + }) + + describe("GET /admin/analytics-config", () => { + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should retrieve config for logged in user if it exists", async () => { + // Create config + await simpleAnalyticsConfigFactory(dbConnection) + + const api = useApi() + const response = await api.get(`/admin/analytics-configs`, adminReqConfig) + + expect(response.data).toMatchSnapshot({ + analytics_config: { + id: expect.any(String), + user_id: "admin_user", + opt_out: false, + anonymize: false, + created_at: expect.any(String), + updated_at: expect.any(String), + }, + }) + }) + + it("should return 404 if no config exists", async () => { + const api = useApi() + + const err = await api + .get(`/admin/analytics-configs`, adminReqConfig) + .catch((err) => err) + + expect(err).toBeTruthy() + expect(err.response.status).toEqual(404) + expect(err.response.data.message).toEqual( + "No analytics config found for user with id: admin_user" + ) + }) + }) + + describe("POST /admin/analytics-config", () => { + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should create a new config for logged in user", async () => { + const api = useApi() + const response = await api + .post( + `/admin/analytics-configs`, + { + opt_out: true, + anonymize: true, + }, + adminReqConfig + ) + .catch((e) => console.log(e)) + + expect(response.data).toMatchSnapshot({ + analytics_config: { + id: expect.any(String), + user_id: "admin_user", + opt_out: true, + anonymize: true, + created_at: expect.any(String), + updated_at: expect.any(String), + }, + }) + }) + }) + + describe("POST /admin/analytics-config/update", () => { + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should update the config of the logged in user", async () => { + // Create config + await simpleAnalyticsConfigFactory(dbConnection) + + const api = useApi() + const response = await api.post( + `/admin/analytics-configs/update`, + { + opt_out: true, + }, + adminReqConfig + ) + + expect(response.data).toMatchSnapshot({ + analytics_config: { + id: expect.any(String), + user_id: "admin_user", + opt_out: true, + anonymize: false, + created_at: expect.any(String), + updated_at: expect.any(String), + }, + }) + }) + + it("should create a config for the user is no config exists", async () => { + const api = useApi() + const res = await api.post( + `/admin/analytics-configs/update`, + { + opt_out: true, + }, + adminReqConfig + ) + + expect(res.data).toMatchSnapshot({ + analytics_config: { + id: expect.any(String), + user_id: "admin_user", + opt_out: true, + anonymize: false, + created_at: expect.any(String), + updated_at: expect.any(String), + }, + }) + }) + }) + + describe("DELETE /admin/analytics-config", () => { + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should delete the config of the logged in user", async () => { + // Create config + await simpleAnalyticsConfigFactory(dbConnection) + + const api = useApi() + const response = await api.delete( + `/admin/analytics-configs`, + adminReqConfig + ) + + expect(response.status).toEqual(200) + expect(response.data).toEqual({ + user_id: "admin_user", + object: "analytics_config", + deleted: true, + }) + }) + }) +}) diff --git a/integration-tests/api/__tests__/admin/user.js b/integration-tests/api/__tests__/admin/user.js index 5baab404e4..aea7893564 100644 --- a/integration-tests/api/__tests__/admin/user.js +++ b/integration-tests/api/__tests__/admin/user.js @@ -7,9 +7,20 @@ const { initDb, useDb } = require("../../../helpers/use-db") const userSeeder = require("../../helpers/user-seeder") const adminSeeder = require("../../helpers/admin-seeder") +const { + simpleAnalyticsConfigFactory, +} = require("../../factories/simple-analytics-config-factory") +const startServerWithEnvironment = + require("../../../helpers/start-server-with-environment").default jest.setTimeout(30000) +const adminReqConfig = { + headers: { + Authorization: "Bearer test_token", + }, +} + describe("/admin/users", () => { let medusaProcess let dbConnection @@ -17,7 +28,7 @@ describe("/admin/users", () => { beforeAll(async () => { const cwd = path.resolve(path.join(__dirname, "..", "..")) dbConnection = await initDb({ cwd }) - medusaProcess = await setupServer({ cwd }) + medusaProcess = await setupServer({ cwd, verbose: false }) }) afterAll(async () => { @@ -40,9 +51,7 @@ describe("/admin/users", () => { it("returns user by id", async () => { const api = useApi() - const response = await api.get("/admin/users/admin_user", { - headers: { Authorization: "Bearer test_token " }, - }) + const response = await api.get("/admin/users/admin_user", adminReqConfig) expect(response.status).toEqual(200) expect(response.data.user).toMatchSnapshot({ @@ -59,11 +68,7 @@ describe("/admin/users", () => { const api = useApi() const response = await api - .get("/admin/users", { - headers: { - Authorization: "Bearer test_token", - }, - }) + .get("/admin/users", adminReqConfig) .catch((err) => { console.log(err) }) @@ -107,9 +112,7 @@ describe("/admin/users", () => { role: "member", password: "test123453", }, - { - headers: { Authorization: "Bearer test_token" }, - } + adminReqConfig ) .catch((err) => console.log(err)) @@ -131,9 +134,7 @@ describe("/admin/users", () => { } const response = await api - .post("/admin/users", payload, { - headers: { Authorization: "Bearer test_token" }, - }) + .post("/admin/users", payload, adminReqConfig) .catch((err) => console.log(err)) expect(response.status).toEqual(200) @@ -153,9 +154,7 @@ describe("/admin/users", () => { .post( "/admin/users/member-user", { first_name: "karl" }, - { - headers: { Authorization: "Bearer test_token " }, - } + adminReqConfig ) .catch((err) => console.log(err.response.data.message)) @@ -314,26 +313,22 @@ describe("/admin/users", () => { const userId = "member-user" - const usersBeforeDeleteResponse = await api.get("/admin/users", { - headers: { - Authorization: "Bearer test_token", - }, - }) + const usersBeforeDeleteResponse = await api.get( + "/admin/users", + adminReqConfig + ) const usersBeforeDelete = usersBeforeDeleteResponse.data.users - const response = await api - .delete(`/admin/users/${userId}`, { - headers: { Authorization: "Bearer test_token" }, - }) - .catch((err) => console.log(err)) - - const usersAfterDeleteResponse = await api.get("/admin/users", { - headers: { - Authorization: "Bearer test_token", - }, + const response = await api.delete(`/admin/users/${userId}`, { + headers: { Authorization: "Bearer test_token" }, }) + const usersAfterDeleteResponse = await api.get( + "/admin/users", + adminReqConfig + ) + expect(response.status).toEqual(200) expect(response.data).toEqual({ id: userId, @@ -354,3 +349,75 @@ describe("/admin/users", () => { }) }) }) + +describe("[MEDUSA_FF_ANALYTICS] /admin/analytics-config", () => { + let medusaProcess + let dbConnection + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + const [process, connection] = await startServerWithEnvironment({ + cwd, + env: { MEDUSA_FF_ANALYTICS: true }, + }) + dbConnection = connection + medusaProcess = process + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + + medusaProcess.kill() + }) + + describe("DELETE /admin/users", () => { + beforeEach(async () => { + await adminSeeder(dbConnection) + await userSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("Deletes a user and their analytics config", async () => { + const api = useApi() + + const userId = "member-user" + + await simpleAnalyticsConfigFactory(dbConnection, { + user_id: userId, + }) + + const response = await api.delete( + `/admin/users/${userId}`, + adminReqConfig + ) + + expect(response.status).toEqual(200) + expect(response.data).toEqual({ + id: userId, + object: "user", + deleted: true, + }) + + const configs = await dbConnection.manager.query( + `SELECT * FROM public.analytics_config WHERE user_id = '${userId}'` + ) + + expect(configs).toMatchSnapshot([ + { + created_at: expect.any(Date), + updated_at: expect.any(Date), + deleted_at: expect.any(Date), + id: expect.any(String), + user_id: userId, + opt_out: false, + anonymize: false, + }, + ]) + }) + }) +}) diff --git a/integration-tests/api/__tests__/batch-jobs/api.js b/integration-tests/api/__tests__/batch-jobs/api.js index 3ce67820fb..23a04f5092 100644 --- a/integration-tests/api/__tests__/batch-jobs/api.js +++ b/integration-tests/api/__tests__/batch-jobs/api.js @@ -232,7 +232,7 @@ describe("/admin/batch-jobs", () => { const jobId = "job_4" - api + await api .post(`/admin/batch-jobs/${jobId}/confirm`, {}, adminReqConfig) .catch((err) => { expect(err.response.status).toEqual(400) @@ -286,7 +286,7 @@ describe("/admin/batch-jobs", () => { const jobId = "job_4" - api + await api .post(`/admin/batch-jobs/${jobId}/cancel`, {}, adminReqConfig) .catch((err) => { expect(err.response.status).toEqual(400) @@ -306,6 +306,7 @@ describe("/admin/batch-jobs", () => { await api .post(`/admin/batch-jobs/${jobId}/cancel`, {}, adminReqConfig) .catch((err) => { + console.log(err) expect(err.response.status).toEqual(400) expect(err.response.data.type).toEqual("not_allowed") expect(err.response.data.message).toEqual( diff --git a/integration-tests/api/__tests__/batch-jobs/price-list/import.js b/integration-tests/api/__tests__/batch-jobs/price-list/import.js index 8979f8e533..90a91ca6f3 100644 --- a/integration-tests/api/__tests__/batch-jobs/price-list/import.js +++ b/integration-tests/api/__tests__/batch-jobs/price-list/import.js @@ -95,13 +95,31 @@ describe("Price list import batch job", () => { copyTemplateFile() const product = await simpleProductFactory(dbConnection, { + options: [ + { + title: "Size", + id: "size", + }, + ], variants: [ { id: "test-pl-variant", + options: [ + { + option_id: "size", + value: "S", + }, + ], }, { id: "test-pl-sku-variant", sku: "pl-sku", + options: [ + { + option_id: "size", + value: "M", + }, + ], }, ], }) @@ -216,8 +234,13 @@ describe("Price list import batch job", () => { const product = await simpleProductFactory(dbConnection, { variants: [ - { id: "test-pl-variant" }, - { id: "test-pl-sku-variant", sku: "pl-sku" }, + { + id: "test-pl-variant", + }, + { + id: "test-pl-sku-variant", + sku: "pl-sku", + }, ], }) diff --git a/integration-tests/api/__tests__/batch-jobs/product/ff-sales-channel.js b/integration-tests/api/__tests__/batch-jobs/product/ff-sales-channel.js index 10645e26fc..50b900d3af 100644 --- a/integration-tests/api/__tests__/batch-jobs/product/ff-sales-channel.js +++ b/integration-tests/api/__tests__/batch-jobs/product/ff-sales-channel.js @@ -14,6 +14,26 @@ const startServerWithEnvironment = jest.setTimeout(30000) +function getImportFile() { + return path.resolve( + "__tests__", + "batch-jobs", + "product", + "product-import-ss.csv" + ) +} + +function copyTemplateFile() { + const csvTemplate = path.resolve( + "__tests__", + "batch-jobs", + "product", + "product-import-ss-template.csv" + ) + const destination = getImportFile() + fs.copyFileSync(csvTemplate, destination) +} + const adminReqConfig = { headers: { Authorization: "Bearer test_token", @@ -82,6 +102,8 @@ describe("Product import - Sales Channel", () => { jest.setTimeout(1000000) const api = useApi() + copyTemplateFile() + const response = await api.post( "/admin/batch-jobs", { diff --git a/integration-tests/api/__tests__/batch-jobs/product/import.js b/integration-tests/api/__tests__/batch-jobs/product/import.js index f2ebab8248..8d5483a07b 100644 --- a/integration-tests/api/__tests__/batch-jobs/product/import.js +++ b/integration-tests/api/__tests__/batch-jobs/product/import.js @@ -16,6 +16,26 @@ const adminReqConfig = { }, } +function getImportFile() { + return path.resolve( + "__tests__", + "batch-jobs", + "product", + "product-import.csv" + ) +} + +function copyTemplateFile() { + const csvTemplate = path.resolve( + "__tests__", + "batch-jobs", + "product", + "product-import-template.csv" + ) + const destination = getImportFile() + fs.copyFileSync(csvTemplate, destination) +} + jest.setTimeout(1000000) function cleanTempData() { @@ -72,6 +92,8 @@ describe("Product import batch job", () => { jest.setTimeout(1000000) const api = useApi() + copyTemplateFile() + const existingProductToBeUpdated = await simpleProductFactory( dbConnection, { diff --git a/integration-tests/api/__tests__/batch-jobs/product/product-import-ss.csv b/integration-tests/api/__tests__/batch-jobs/product/product-import-ss-template.csv similarity index 100% rename from integration-tests/api/__tests__/batch-jobs/product/product-import-ss.csv rename to integration-tests/api/__tests__/batch-jobs/product/product-import-ss-template.csv diff --git a/integration-tests/api/__tests__/batch-jobs/product/product-import.csv b/integration-tests/api/__tests__/batch-jobs/product/product-import-template.csv similarity index 100% rename from integration-tests/api/__tests__/batch-jobs/product/product-import.csv rename to integration-tests/api/__tests__/batch-jobs/product/product-import-template.csv diff --git a/integration-tests/api/__tests__/claims/index.js b/integration-tests/api/__tests__/claims/index.js index 1f7f4b0873..f84a7c299f 100644 --- a/integration-tests/api/__tests__/claims/index.js +++ b/integration-tests/api/__tests__/claims/index.js @@ -25,7 +25,7 @@ describe("Claims", () => { beforeAll(async () => { const cwd = path.resolve(path.join(__dirname, "..", "..")) dbConnection = await initDb({ cwd }) - medusaProcess = await setupServer({ cwd, verbose: true }) + medusaProcess = await setupServer({ cwd }) }) afterAll(async () => { diff --git a/integration-tests/api/factories/simple-analytics-config-factory.ts b/integration-tests/api/factories/simple-analytics-config-factory.ts new file mode 100644 index 0000000000..4512d87311 --- /dev/null +++ b/integration-tests/api/factories/simple-analytics-config-factory.ts @@ -0,0 +1,25 @@ +import { AnalyticsConfig } from "@medusajs/medusa" +import { Connection } from "typeorm" + +export type AnalyticsConfigData = { + id?: string + user_id?: string + opt_out?: boolean + anonymize?: boolean +} + +export const simpleAnalyticsConfigFactory = async ( + connection: Connection, + data: AnalyticsConfigData = {} +): Promise => { + const manager = connection.manager + + const job = manager.create(AnalyticsConfig, { + id: data.id ?? "test-analytics-config", + user_id: data.user_id ?? "admin_user", + opt_out: data.opt_out ?? false, + anonymize: data.anonymize ?? false, + }) + + return await manager.save(job) +} diff --git a/packages/medusa/src/api/index.js b/packages/medusa/src/api/index.js index d1d54b278a..6055ad30e7 100644 --- a/packages/medusa/src/api/index.js +++ b/packages/medusa/src/api/index.js @@ -16,6 +16,7 @@ export default (container, config) => { } // Admin +export * from "./routes/admin/analytics-configs" export * from "./routes/admin/auth" export * from "./routes/admin/batch" export * from "./routes/admin/collections" diff --git a/packages/medusa/src/api/routes/admin/analytics-configs/create-analytics-config.ts b/packages/medusa/src/api/routes/admin/analytics-configs/create-analytics-config.ts new file mode 100644 index 0000000000..bd0a5d6193 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/analytics-configs/create-analytics-config.ts @@ -0,0 +1,33 @@ +import { IsBoolean } from "class-validator" +import { Request, Response } from "express" +import { EntityManager } from "typeorm" +import { AnalyticsConfigService } from "../../../../services" +import { CreateAnalyticsConfig } from "../../../../types/analytics-config" + +// No OAS for this route, for internal use only. +export default async (req: Request, res: Response) => { + const userId = (req.user?.userId ?? req.user?.id)! + const validatedBody = req.validatedBody as CreateAnalyticsConfig + const analyticsConfigService: AnalyticsConfigService = req.scope.resolve( + "analyticsConfigService" + ) + + const manager: EntityManager = req.scope.resolve("manager") + const analyticsConfig = await manager.transaction( + async (transactionManager) => { + return await analyticsConfigService + .withTransaction(transactionManager) + .create(userId, validatedBody) + } + ) + + res.status(200).json({ analytics_config: analyticsConfig }) +} + +export class AdminPostAnalyticsConfigReq { + @IsBoolean() + opt_out: boolean + + @IsBoolean() + anonymize?: boolean = false +} diff --git a/packages/medusa/src/api/routes/admin/analytics-configs/delete-analytics-config.ts b/packages/medusa/src/api/routes/admin/analytics-configs/delete-analytics-config.ts new file mode 100644 index 0000000000..56fbcbe0cd --- /dev/null +++ b/packages/medusa/src/api/routes/admin/analytics-configs/delete-analytics-config.ts @@ -0,0 +1,23 @@ +import { Request, Response } from "express" +import { EntityManager } from "typeorm" +import { AnalyticsConfigService } from "../../../../services" + +// No OAS for this route, for internal use only. +export default async (req: Request, res: Response) => { + const userId = (req.user?.userId ?? req.user?.id)! + + const analyticsConfigService: AnalyticsConfigService = req.scope.resolve( + "analyticsConfigService" + ) + const manager: EntityManager = req.scope.resolve("manager") + + await manager.transaction(async (transactionManager) => { + return await analyticsConfigService + .withTransaction(transactionManager) + .delete(userId) + }) + + res + .status(200) + .json({ user_id: userId, object: "analytics_config", deleted: true }) +} diff --git a/packages/medusa/src/api/routes/admin/analytics-configs/get-analytics-config.ts b/packages/medusa/src/api/routes/admin/analytics-configs/get-analytics-config.ts new file mode 100644 index 0000000000..4ff2450b07 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/analytics-configs/get-analytics-config.ts @@ -0,0 +1,14 @@ +import { Request, Response } from "express" +import { AnalyticsConfigService } from "../../../../services" + +// No OAS for this route, for internal use only. +export default async (req: Request, res: Response): Promise => { + const userId = (req.user?.userId ?? req.user?.id)! + + const analyticsConfigService: AnalyticsConfigService = req.scope.resolve( + "analyticsConfigService" + ) + + const analyticsConfig = await analyticsConfigService.retrieve(userId) + res.status(200).json({ analytics_config: analyticsConfig }) +} diff --git a/packages/medusa/src/api/routes/admin/analytics-configs/index.ts b/packages/medusa/src/api/routes/admin/analytics-configs/index.ts new file mode 100644 index 0000000000..3847efb732 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/analytics-configs/index.ts @@ -0,0 +1,43 @@ +import { Router } from "express" +import { AnalyticsConfig } from "../../../.." +import { DeleteResponse } from "../../../../types/common" +import middlewares, { transformBody } from "../../../middlewares" +import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled" +import { AdminPostAnalyticsConfigReq } from "./create-analytics-config" +import { AdminPostAnalyticsConfigAnalyticsConfigReq } from "./update-analytics-config" + +const route = Router() + +export default (app: Router) => { + app.use("/analytics-configs", isFeatureFlagEnabled("analytics"), route) + + route.get("/", middlewares.wrap(require("./get-analytics-config").default)) + + route.post( + "/", + transformBody(AdminPostAnalyticsConfigReq), + middlewares.wrap(require("./create-analytics-config").default) + ) + + route.post( + "/update", + transformBody(AdminPostAnalyticsConfigAnalyticsConfigReq), + middlewares.wrap(require("./update-analytics-config").default) + ) + + route.delete( + "/", + middlewares.wrap(require("./delete-analytics-config").default) + ) + + return app +} + +export type AdminAnalyticsConfigRes = { + analytics_config: AnalyticsConfig +} + +export type AdminAnalyticsConfigDeleteRes = DeleteResponse + +export * from "./create-analytics-config" +export * from "./update-analytics-config" diff --git a/packages/medusa/src/api/routes/admin/analytics-configs/update-analytics-config.ts b/packages/medusa/src/api/routes/admin/analytics-configs/update-analytics-config.ts new file mode 100644 index 0000000000..9e4923fde2 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/analytics-configs/update-analytics-config.ts @@ -0,0 +1,35 @@ +import { IsBoolean, IsOptional } from "class-validator" +import { Request, Response } from "express" +import { EntityManager } from "typeorm" +import { AnalyticsConfigService } from "../../../../services" +import { UpdateAnalyticsConfig } from "../../../../types/analytics-config" + +// No OAS for this route, for internal use only. +export default async (req: Request, res: Response) => { + const userId = (req.user?.userId ?? req.user?.id)! + const validatedBody = req.validatedBody as UpdateAnalyticsConfig + const analyticsConfigService: AnalyticsConfigService = req.scope.resolve( + "analyticsConfigService" + ) + + const manager: EntityManager = req.scope.resolve("manager") + const analyticsConfig = await manager.transaction( + async (transactionManager) => { + return await analyticsConfigService + .withTransaction(transactionManager) + .update(userId, validatedBody) + } + ) + + res.status(200).json({ analytics_config: analyticsConfig }) +} + +export class AdminPostAnalyticsConfigAnalyticsConfigReq { + @IsOptional() + @IsBoolean() + opt_out?: boolean + + @IsOptional() + @IsBoolean() + anonymize?: boolean +} diff --git a/packages/medusa/src/api/routes/admin/index.js b/packages/medusa/src/api/routes/admin/index.js index ac5225f925..57b32643e8 100644 --- a/packages/medusa/src/api/routes/admin/index.js +++ b/packages/medusa/src/api/routes/admin/index.js @@ -1,6 +1,7 @@ import cors from "cors" import { Router } from "express" import middlewares from "../../middlewares" +import analyticsConfigs from "./analytics-configs" import appRoutes from "./apps" import authRoutes from "./auth" import batchRoutes from "./batch" @@ -67,6 +68,7 @@ export default (app, container, config) => { // Calls all middleware that has been registered to run after authentication. middlewareService.usePostAuthentication(app) + analyticsConfigs(route) appRoutes(route) batchRoutes(route) collectionRoutes(route) diff --git a/packages/medusa/src/loaders/feature-flags/analytics.ts b/packages/medusa/src/loaders/feature-flags/analytics.ts new file mode 100644 index 0000000000..dc800e1ad1 --- /dev/null +++ b/packages/medusa/src/loaders/feature-flags/analytics.ts @@ -0,0 +1,11 @@ +import { FlagSettings } from "../../types/feature-flags" + +const AnalyticsFeatureFlag: FlagSettings = { + key: "analytics", + default_val: false, + env_key: "MEDUSA_FF_ANALYTICS", + description: + "Enable Medusa to collect data on usage, errors and performance for the purpose of improving the product", +} + +export default AnalyticsFeatureFlag diff --git a/packages/medusa/src/migrations/1666173221888-add_analytics_config.ts b/packages/medusa/src/migrations/1666173221888-add_analytics_config.ts new file mode 100644 index 0000000000..7312e3193a --- /dev/null +++ b/packages/medusa/src/migrations/1666173221888-add_analytics_config.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import AnalyticsFeatureFlag from "../loaders/feature-flags/analytics" + +export const featureFlag = AnalyticsFeatureFlag.key + +export class addAnalyticsConfig1666173221888 implements MigrationInterface { + name = "addAnalyticsConfig1666173221888" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "analytics_config" ("id" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deleted_at" TIMESTAMP WITH TIME ZONE, "user_id" character varying NOT NULL, "opt_out" boolean NOT NULL DEFAULT false, "anonymize" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_93505647c5d7cb479becb810b0f" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_379ca70338ce9991f3affdeedf" ON "analytics_config" ("id", "user_id") WHERE deleted_at IS NULL` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "public"."IDX_379ca70338ce9991f3affdeedf"` + ) + await queryRunner.query(`DROP TABLE "analytics_config"`) + } +} diff --git a/packages/medusa/src/models/analytics-config.ts b/packages/medusa/src/models/analytics-config.ts new file mode 100644 index 0000000000..0229f84ece --- /dev/null +++ b/packages/medusa/src/models/analytics-config.ts @@ -0,0 +1,23 @@ +import { BeforeInsert, Column, Index } from "typeorm" +import { SoftDeletableEntity } from "../interfaces" +import AnalyticsFeatureFlag from "../loaders/feature-flags/analytics" +import { generateEntityId } from "../utils" +import { FeatureFlagEntity } from "../utils/feature-flag-decorators" + +@FeatureFlagEntity(AnalyticsFeatureFlag.key) +export class AnalyticsConfig extends SoftDeletableEntity { + @Index({ unique: true, where: "deleted_at IS NULL" }) + @Column() + user_id: string + + @Column({ default: false }) + opt_out: boolean + + @Column({ default: false }) + anonymize: boolean + + @BeforeInsert() + private beforeInsert(): void { + this.id = generateEntityId(this.id, "acfg") + } +} diff --git a/packages/medusa/src/models/index.ts b/packages/medusa/src/models/index.ts index 5f074a4075..54c9f843b2 100644 --- a/packages/medusa/src/models/index.ts +++ b/packages/medusa/src/models/index.ts @@ -1,4 +1,5 @@ export * from "./address" +export * from "./analytics-config" export * from "./batch-job" export * from "./cart" export * from "./claim-image" diff --git a/packages/medusa/src/repositories/analytics-config.ts b/packages/medusa/src/repositories/analytics-config.ts new file mode 100644 index 0000000000..54762f57d4 --- /dev/null +++ b/packages/medusa/src/repositories/analytics-config.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from "typeorm" +import { AnalyticsConfig } from "../models/analytics-config" + +@EntityRepository(AnalyticsConfig) +export class AnalyticsConfigRepository extends Repository {} diff --git a/packages/medusa/src/services/analytics-config.ts b/packages/medusa/src/services/analytics-config.ts new file mode 100644 index 0000000000..149310c8a7 --- /dev/null +++ b/packages/medusa/src/services/analytics-config.ts @@ -0,0 +1,119 @@ +import { MedusaError } from "medusa-core-utils" +import { EntityManager } from "typeorm" +import { TransactionBaseService } from "../interfaces" +import { AnalyticsConfig } from "../models" +import { AnalyticsConfigRepository as AnalyticsRepository } from "../repositories/analytics-config" +import { + CreateAnalyticsConfig, + UpdateAnalyticsConfig, +} from "../types/analytics-config" +import UserService from "./user" + +type InjectedDependencies = { + analyticsConfigRepository: typeof AnalyticsRepository + manager: EntityManager +} + +class AnalyticsConfigService extends TransactionBaseService { + protected manager_: EntityManager + protected transactionManager_: EntityManager | undefined + + protected readonly analyticsConfigRepository_: typeof AnalyticsRepository + protected readonly userService_: UserService + + constructor({ analyticsConfigRepository, manager }: InjectedDependencies) { + // eslint-disable-next-line prefer-rest-params + super(arguments[0]) + + this.manager_ = manager + this.analyticsConfigRepository_ = analyticsConfigRepository + } + + async retrieve(userId: string): Promise { + const manager = this.manager_ + + const analyticsRepo = manager.getCustomRepository( + this.analyticsConfigRepository_ + ) + + const analyticsConfig = await analyticsRepo.findOne({ + where: { user_id: userId }, + }) + + if (!analyticsConfig) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `No analytics config found for user with id: ${userId}` + ) + } + + return analyticsConfig + } + + /** + * Creates an analytics config. + */ + async create( + userId: string, + data: CreateAnalyticsConfig + ): Promise { + const manager = this.transactionManager_ || this.manager_ + const analyticsRepo = manager.getCustomRepository( + this.analyticsConfigRepository_ + ) + + const config = analyticsRepo.create({ user_id: userId, ...data }) + return await analyticsRepo.save(config) + } + + /** + * Updates an analytics config. If the config does not exist, it will be created instead. + */ + async update( + userId: string, + update: UpdateAnalyticsConfig + ): Promise { + const manager = this.transactionManager_ || this.manager_ + + const analyticsRepo = manager.getCustomRepository( + this.analyticsConfigRepository_ + ) + + const config = await this.retrieve(userId).catch(() => undefined) + + if (!config) { + return this.create(userId, { + opt_out: update.opt_out ?? false, + anonymize: update.anonymize ?? false, + }) + } + + for (const [key, value] of Object.entries(update)) { + if (value !== undefined) { + config[key] = value + } + } + + return await analyticsRepo.save(config) + } + + /** + * Deletes an analytics config. + */ + async delete(userId: string): Promise { + const manager = this.transactionManager_ || this.manager_ + const analyticsRepo = manager.getCustomRepository( + this.analyticsConfigRepository_ + ) + + const config = await this.retrieve(userId).catch(() => undefined) + + if (!config) { + return + } + + await analyticsRepo.softRemove(config) + } +} + +export default AnalyticsConfigService diff --git a/packages/medusa/src/services/index.ts b/packages/medusa/src/services/index.ts index 817d0097e9..3eed30e274 100644 --- a/packages/medusa/src/services/index.ts +++ b/packages/medusa/src/services/index.ts @@ -1,3 +1,4 @@ +export { default as AnalyticsConfigService } from "./analytics-config" export { default as AuthService } from "./auth" export { default as BatchJobService } from "./batch-job" export { default as CartService } from "./cart" diff --git a/packages/medusa/src/services/user.ts b/packages/medusa/src/services/user.ts index 228e335f88..42e2b0cb1e 100644 --- a/packages/medusa/src/services/user.ts +++ b/packages/medusa/src/services/user.ts @@ -3,6 +3,7 @@ import { MedusaError } from "medusa-core-utils" import Scrypt from "scrypt-kdf" import { EntityManager } from "typeorm" import { TransactionBaseService } from "../interfaces" +import AnalyticsFeatureFlag from "../loaders/feature-flags/analytics" import { User } from "../models" import { UserRepository } from "../repositories/user" import { FindConfig } from "../types/common" @@ -12,13 +13,17 @@ import { UpdateUserInput, } from "../types/user" import { buildQuery, setMetadata } from "../utils" +import { FlagRouter } from "../utils/flag-router" import { validateEmail } from "../utils/is-email" +import AnalyticsConfigService from "./analytics-config" import EventBusService from "./event-bus" type UserServiceProps = { userRepository: typeof UserRepository + analyticsConfigService: AnalyticsConfigService eventBusService: EventBusService manager: EntityManager + featureFlagRouter: FlagRouter } /** @@ -34,13 +39,24 @@ class UserService extends TransactionBaseService { protected manager_: EntityManager protected transactionManager_: EntityManager + protected readonly analyticsConfigService_: AnalyticsConfigService protected readonly userRepository_: typeof UserRepository protected readonly eventBus_: EventBusService + protected readonly featureFlagRouter_: FlagRouter - constructor({ userRepository, eventBusService, manager }: UserServiceProps) { - super({ userRepository, eventBusService, manager }) + constructor({ + userRepository, + eventBusService, + analyticsConfigService, + featureFlagRouter, + manager, + }: UserServiceProps) { + // eslint-disable-next-line prefer-rest-params + super(arguments[0]) this.userRepository_ = userRepository + this.analyticsConfigService_ = analyticsConfigService + this.featureFlagRouter_ = featureFlagRouter this.eventBus_ = eventBusService this.manager_ = manager } @@ -236,6 +252,8 @@ class UserService extends TransactionBaseService { async delete(userId: string): Promise { return await this.atomicPhase_(async (manager: EntityManager) => { const userRepo = manager.getCustomRepository(this.userRepository_) + const analyticsServiceTx = + this.analyticsConfigService_.withTransaction(manager) // Should not fail, if user does not exist, since delete is idempotent const user = await userRepo.findOne({ where: { id: userId } }) @@ -244,6 +262,10 @@ class UserService extends TransactionBaseService { return Promise.resolve() } + if (this.featureFlagRouter_.isFeatureEnabled(AnalyticsFeatureFlag.key)) { + await analyticsServiceTx.delete(userId) + } + await userRepo.softRemove(user) await this.eventBus_.emit(UserService.Events.DELETED, { id: user.id }) diff --git a/packages/medusa/src/types/analytics-config.ts b/packages/medusa/src/types/analytics-config.ts new file mode 100644 index 0000000000..24f71b8530 --- /dev/null +++ b/packages/medusa/src/types/analytics-config.ts @@ -0,0 +1,9 @@ +export type CreateAnalyticsConfig = { + opt_out: boolean + anonymize: boolean +} + +export type UpdateAnalyticsConfig = { + opt_out?: boolean + anonymize?: boolean +}