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 +}