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
This commit is contained in:
Kasper Fabricius Kristensen
2022-10-21 15:04:46 +02:00
committed by GitHub
parent f83c238a26
commit 4de4f20b46
28 changed files with 842 additions and 40 deletions

View File

@@ -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<String>,
"deleted_at": null,
"id": Any<String>,
"opt_out": false,
"updated_at": Any<String>,
"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<String>,
"deleted_at": null,
"id": Any<String>,
"opt_out": true,
"updated_at": Any<String>,
"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<String>,
"deleted_at": null,
"id": Any<String>,
"opt_out": true,
"updated_at": Any<String>,
"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<String>,
"deleted_at": null,
"id": Any<String>,
"opt_out": true,
"updated_at": Any<String>,
"user_id": "admin_user",
},
}
`;

View File

@@ -74,3 +74,17 @@ Object {
"updated_at": Any<String>,
}
`;
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<Date>,
"deleted_at": Any<Date>,
"id": Any<String>,
"opt_out": false,
"updated_at": Any<Date>,
"user_id": "member-user",
},
]
`;

View File

@@ -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,
})
})
})
})

View File

@@ -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,
},
])
})
})
})

View File

@@ -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(

View File

@@ -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",
},
],
})

View File

@@ -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",
{

View File

@@ -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,
{

View File

@@ -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 () => {

View File

@@ -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<AnalyticsConfig> => {
const manager = connection.manager
const job = manager.create<AnalyticsConfig>(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<AnalyticsConfig>(job)
}

View File

@@ -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"

View File

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

View File

@@ -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 })
}

View File

@@ -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<void> => {
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 })
}

View File

@@ -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"

View File

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

View File

@@ -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)

View File

@@ -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

View File

@@ -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<void> {
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<void> {
await queryRunner.query(
`DROP INDEX "public"."IDX_379ca70338ce9991f3affdeedf"`
)
await queryRunner.query(`DROP TABLE "analytics_config"`)
}
}

View File

@@ -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")
}
}

View File

@@ -1,4 +1,5 @@
export * from "./address"
export * from "./analytics-config"
export * from "./batch-job"
export * from "./cart"
export * from "./claim-image"

View File

@@ -0,0 +1,5 @@
import { EntityRepository, Repository } from "typeorm"
import { AnalyticsConfig } from "../models/analytics-config"
@EntityRepository(AnalyticsConfig)
export class AnalyticsConfigRepository extends Repository<AnalyticsConfig> {}

View File

@@ -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<AnalyticsConfig> {
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<AnalyticsConfig> {
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<AnalyticsConfig> {
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<void> {
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

View File

@@ -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"

View File

@@ -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<void> {
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 })

View File

@@ -0,0 +1,9 @@
export type CreateAnalyticsConfig = {
opt_out: boolean
anonymize: boolean
}
export type UpdateAnalyticsConfig = {
opt_out?: boolean
anonymize?: boolean
}