diff --git a/.changeset/tame-kings-battle.md b/.changeset/tame-kings-battle.md new file mode 100644 index 0000000000..dbceb8db05 --- /dev/null +++ b/.changeset/tame-kings-battle.md @@ -0,0 +1,7 @@ +--- +"@medusajs/core-flows": patch +"@medusajs/medusa": patch +"@medusajs/types": patch +--- + +feat(core-flows, types, utils, medusa): add user endpoints to api-v2 diff --git a/integration-tests/plugins/__tests__/users/create-user.spec.ts b/integration-tests/plugins/__tests__/users/create-user.spec.ts new file mode 100644 index 0000000000..9776700e5f --- /dev/null +++ b/integration-tests/plugins/__tests__/users/create-user.spec.ts @@ -0,0 +1,58 @@ +import { initDb, useDb } from "../../../environment-helpers/use-db" + +import { IUserModuleService } from "@medusajs/types" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { getContainer } from "../../../environment-helpers/use-container" +import path from "path" +import { startBootstrapApp } from "../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../environment-helpers/use-api" +import adminSeeder from "../../../helpers/admin-seeder" +import { AxiosInstance } from "axios" + +jest.setTimeout(50000) + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("POST /admin/users", () => { + let dbConnection + let shutdownServer + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("create a user", async () => { + const api = useApi()! as AxiosInstance + + const body = { + email: "test_member@test.com", + } + + const response = await api.post(`/admin/users`, body, adminHeaders) + + expect(response.status).toEqual(200) + expect(response.data).toEqual({ + user: expect.objectContaining(body), + }) + }) +}) diff --git a/integration-tests/plugins/__tests__/users/delete-user.spec.ts b/integration-tests/plugins/__tests__/users/delete-user.spec.ts new file mode 100644 index 0000000000..30d6cb4b23 --- /dev/null +++ b/integration-tests/plugins/__tests__/users/delete-user.spec.ts @@ -0,0 +1,71 @@ +import { initDb, useDb } from "../../../environment-helpers/use-db" + +import { IUserModuleService } from "@medusajs/types" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { getContainer } from "../../../environment-helpers/use-container" +import path from "path" +import { startBootstrapApp } from "../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../environment-helpers/use-api" +import adminSeeder from "../../../helpers/admin-seeder" +import { AxiosInstance } from "axios" + +jest.setTimeout(50000) + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("DELETE /admin/users/:id", () => { + let dbConnection + let appContainer + let shutdownServer + let userModuleService: IUserModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + userModuleService = appContainer.resolve(ModuleRegistrationName.USER) + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should delete a single user", async () => { + const user = await userModuleService.create({ + email: "member@test.com", + }) + + const api = useApi()! as AxiosInstance + + const response = await api.delete(`/admin/users/${user.id}`, adminHeaders) + + expect(response.status).toEqual(200) + expect(response.data).toEqual({ + id: user.id, + object: "user", + deleted: true, + }) + + const { response: deletedResponse } = await api + .get(`/admin/users/${user.id}`, adminHeaders) + .catch((e) => e) + + expect(deletedResponse.status).toEqual(404) + expect(deletedResponse.data.type).toEqual("not_found") + }) +}) diff --git a/integration-tests/plugins/__tests__/users/list-users.spec.ts b/integration-tests/plugins/__tests__/users/list-users.spec.ts new file mode 100644 index 0000000000..a4144d3756 --- /dev/null +++ b/integration-tests/plugins/__tests__/users/list-users.spec.ts @@ -0,0 +1,72 @@ +import { initDb, useDb } from "../../../environment-helpers/use-db" + +import { IUserModuleService } from "@medusajs/types" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { getContainer } from "../../../environment-helpers/use-container" +import path from "path" +import { startBootstrapApp } from "../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../environment-helpers/use-api" +import adminSeeder from "../../../helpers/admin-seeder" +import { AxiosInstance } from "axios" + +jest.setTimeout(50000) + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("GET /admin/users", () => { + let dbConnection + let appContainer + let shutdownServer + let userModuleService: IUserModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + userModuleService = appContainer.resolve(ModuleRegistrationName.USER) + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should list users", async () => { + await userModuleService.create([ + { + email: "member@test.com", + }, + ]) + + const api = useApi()! as AxiosInstance + + const response = await api.get(`/admin/users`, adminHeaders) + + expect(response.status).toEqual(200) + expect(response.data).toEqual({ + users: expect.arrayContaining([ + expect.objectContaining({ + email: "admin@medusa.js", + }), + expect.objectContaining({ email: "member@test.com" }), + ]), + count: 2, + offset: 0, + limit: 50, + }) + }) +}) diff --git a/integration-tests/plugins/__tests__/users/retrieve-user.spec.ts b/integration-tests/plugins/__tests__/users/retrieve-user.spec.ts new file mode 100644 index 0000000000..fc6b8f14ca --- /dev/null +++ b/integration-tests/plugins/__tests__/users/retrieve-user.spec.ts @@ -0,0 +1,62 @@ +import { initDb, useDb } from "../../../environment-helpers/use-db" + +import { IUserModuleService } from "@medusajs/types" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { getContainer } from "../../../environment-helpers/use-container" +import path from "path" +import { startBootstrapApp } from "../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../environment-helpers/use-api" +import adminSeeder from "../../../helpers/admin-seeder" +import { AxiosInstance } from "axios" + +jest.setTimeout(50000) + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("GET /admin/users/:id", () => { + let dbConnection + let appContainer + let shutdownServer + let userModuleService: IUserModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + userModuleService = appContainer.resolve(ModuleRegistrationName.USER) + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should retrieve a single user", async () => { + const user = await userModuleService.create({ + email: "member@test.com", + }) + + const api = useApi()! as AxiosInstance + + const response = await api.get(`/admin/users/${user.id}`, adminHeaders) + + expect(response.status).toEqual(200) + expect(response.data.user).toEqual( + expect.objectContaining({ email: "member@test.com" }) + ) + }) +}) diff --git a/integration-tests/plugins/__tests__/users/update-user.spec.ts b/integration-tests/plugins/__tests__/users/update-user.spec.ts new file mode 100644 index 0000000000..6cad952593 --- /dev/null +++ b/integration-tests/plugins/__tests__/users/update-user.spec.ts @@ -0,0 +1,68 @@ +import { initDb, useDb } from "../../../environment-helpers/use-db" + +import { IUserModuleService } from "@medusajs/types" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { getContainer } from "../../../environment-helpers/use-container" +import path from "path" +import { startBootstrapApp } from "../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../environment-helpers/use-api" +import adminSeeder from "../../../helpers/admin-seeder" +import { AxiosInstance } from "axios" + +jest.setTimeout(50000) + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("POST /admin/users/:id", () => { + let dbConnection + let appContainer + let shutdownServer + let userModuleService: IUserModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + userModuleService = appContainer.resolve(ModuleRegistrationName.USER) + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should update a single user", async () => { + const user = await userModuleService.create({ + email: "member@test.com", + }) + + const api = useApi()! as AxiosInstance + + const body = { + first_name: "John", + last_name: "Doe", + } + const response = await api.post( + `/admin/users/${user.id}`, + body, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.user).toEqual(expect.objectContaining(body)) + }) +}) diff --git a/integration-tests/plugins/medusa-config.js b/integration-tests/plugins/medusa-config.js index 0a7a04fac5..cece0e38f9 100644 --- a/integration-tests/plugins/medusa-config.js +++ b/integration-tests/plugins/medusa-config.js @@ -47,6 +47,11 @@ module.exports = { resources: "shared", resolve: "@medusajs/auth", }, + [Modules.USER]: { + scope: "internal", + resources: "shared", + resolve: "@medusajs/user", + }, [Modules.STOCK_LOCATION]: { scope: "internal", resources: "shared", diff --git a/integration-tests/plugins/package.json b/integration-tests/plugins/package.json index 4579c99c03..dca6ca503d 100644 --- a/integration-tests/plugins/package.json +++ b/integration-tests/plugins/package.json @@ -20,6 +20,7 @@ "@medusajs/product": "workspace:^", "@medusajs/promotion": "workspace:^", "@medusajs/region": "workspace:^", + "@medusajs/user": "workspace:^", "@medusajs/utils": "workspace:^", "@medusajs/workflow-engine-inmemory": "workspace:*", "faker": "^5.5.3", diff --git a/packages/core-flows/src/index.ts b/packages/core-flows/src/index.ts index c31f9fabe4..56e6906010 100644 --- a/packages/core-flows/src/index.ts +++ b/packages/core-flows/src/index.ts @@ -4,3 +4,4 @@ export * as Handlers from "./handlers" export * from "./promotion" export * from "./customer" export * from "./customer-group" +export * from "./user" diff --git a/packages/core-flows/src/user/index.ts b/packages/core-flows/src/user/index.ts new file mode 100644 index 0000000000..68de82c9f9 --- /dev/null +++ b/packages/core-flows/src/user/index.ts @@ -0,0 +1,2 @@ +export * from "./steps" +export * from "./workflows" diff --git a/packages/core-flows/src/user/steps/create-users.ts b/packages/core-flows/src/user/steps/create-users.ts new file mode 100644 index 0000000000..b510bbe6a7 --- /dev/null +++ b/packages/core-flows/src/user/steps/create-users.ts @@ -0,0 +1,22 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { CreateUserDTO, IUserModuleService } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const createUsersStepId = "create-users-step" +export const createUsersStep = createStep( + createUsersStepId, + async (input: CreateUserDTO[], { container }) => { + const service: IUserModuleService = container.resolve( + ModuleRegistrationName.USER + ) + const users = await service.create(input) + return new StepResponse(users) + }, + async (createdUsers, { container }) => { + if (!createdUsers?.length) { + return + } + const service = container.resolve(ModuleRegistrationName.USER) + await service.delete(createdUsers) + } +) diff --git a/packages/core-flows/src/user/steps/delete-users.ts b/packages/core-flows/src/user/steps/delete-users.ts new file mode 100644 index 0000000000..9df37c2790 --- /dev/null +++ b/packages/core-flows/src/user/steps/delete-users.ts @@ -0,0 +1,28 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IUserModuleService } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const deleteUsersStepId = "delete-users-step" +export const deleteUsersStep = createStep( + deleteUsersStepId, + async (input: string[], { container }) => { + const service: IUserModuleService = container.resolve( + ModuleRegistrationName.USER + ) + + await service.softDelete(input) + + return new StepResponse(void 0, input) + }, + async (prevUserIds, { container }) => { + if (!prevUserIds?.length) { + return + } + + const service: IUserModuleService = container.resolve( + ModuleRegistrationName.USER + ) + + await service.restore(prevUserIds) + } +) diff --git a/packages/core-flows/src/user/steps/index.ts b/packages/core-flows/src/user/steps/index.ts new file mode 100644 index 0000000000..8cac98a8a6 --- /dev/null +++ b/packages/core-flows/src/user/steps/index.ts @@ -0,0 +1,3 @@ +export * from "./delete-users" +export * from "./create-users" +export * from "./update-users" diff --git a/packages/core-flows/src/user/steps/update-users.ts b/packages/core-flows/src/user/steps/update-users.ts new file mode 100644 index 0000000000..5e5b3743b4 --- /dev/null +++ b/packages/core-flows/src/user/steps/update-users.ts @@ -0,0 +1,42 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IUserModuleService, UpdateUserDTO } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const updateUsersStepId = "update-users-step" +export const updateUsersStep = createStep( + updateUsersStepId, + async (input: UpdateUserDTO[], { container }) => { + const service: IUserModuleService = container.resolve( + ModuleRegistrationName.USER + ) + + if (!input.length) { + return new StepResponse([], []) + } + + const originalUsers = await service.list({ + id: input.map((u) => u.id), + }) + + const users = await service.update(input) + return new StepResponse(users, originalUsers) + }, + async (originalUsers, { container }) => { + if (!originalUsers?.length) { + return + } + + const service = container.resolve(ModuleRegistrationName.USER) + + await service.update( + originalUsers.map((u) => ({ + id: u.id, + first_name: u.first_name, + last_name: u.last_name, + email: u.email, + avatar_url: u.avatar_url, + metadata: u.metadata, + })) + ) + } +) diff --git a/packages/core-flows/src/user/workflows/create-users.ts b/packages/core-flows/src/user/workflows/create-users.ts new file mode 100644 index 0000000000..ec4187a683 --- /dev/null +++ b/packages/core-flows/src/user/workflows/create-users.ts @@ -0,0 +1,14 @@ +import { CreateUserDTO, UserDTO } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { createUsersStep } from "../steps" +import { UserWorkflow } from "@medusajs/types" + +export const createUsersWorkflowId = "create-users-workflow" +export const createUsersWorkflow = createWorkflow( + createUsersWorkflowId, + ( + input: WorkflowData + ): WorkflowData => { + return createUsersStep(input.users) + } +) diff --git a/packages/core-flows/src/user/workflows/delete-users.ts b/packages/core-flows/src/user/workflows/delete-users.ts new file mode 100644 index 0000000000..1fef9f8df2 --- /dev/null +++ b/packages/core-flows/src/user/workflows/delete-users.ts @@ -0,0 +1,13 @@ +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { deleteUsersStep } from "../steps" +import { UserWorkflow } from "@medusajs/types" + +export const deleteUsersWorkflowId = "delete-user" +export const deleteUsersWorkflow = createWorkflow( + deleteUsersWorkflowId, + ( + input: WorkflowData + ): WorkflowData => { + return deleteUsersStep(input.ids) + } +) diff --git a/packages/core-flows/src/user/workflows/index.ts b/packages/core-flows/src/user/workflows/index.ts new file mode 100644 index 0000000000..8cac98a8a6 --- /dev/null +++ b/packages/core-flows/src/user/workflows/index.ts @@ -0,0 +1,3 @@ +export * from "./delete-users" +export * from "./create-users" +export * from "./update-users" diff --git a/packages/core-flows/src/user/workflows/update-users.ts b/packages/core-flows/src/user/workflows/update-users.ts new file mode 100644 index 0000000000..d441bf2072 --- /dev/null +++ b/packages/core-flows/src/user/workflows/update-users.ts @@ -0,0 +1,14 @@ +import { UserDTO } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { updateUsersStep } from "../steps" +import { UserWorkflow } from "@medusajs/types" + +export const updateUsersWorkflowId = "update-users-workflow" +export const updateUsersWorkflow = createWorkflow( + updateUsersWorkflowId, + ( + input: WorkflowData + ): WorkflowData => { + return updateUsersStep(input.updates) + } +) diff --git a/packages/medusa/src/api-v2/admin/users/[id]/route.ts b/packages/medusa/src/api-v2/admin/users/[id]/route.ts new file mode 100644 index 0000000000..c8a81b9d02 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/users/[id]/route.ts @@ -0,0 +1,62 @@ +import { + ContainerRegistrationKeys, + remoteQueryObjectFromString, +} from "@medusajs/utils" +import { MedusaRequest, MedusaResponse } from "../../../../types/routing" +import { deleteUsersWorkflow, updateUsersWorkflow } from "@medusajs/core-flows" +import { IUserModuleService, UpdateUserDTO } from "@medusajs/types" +import { ModuleRegistrationName } from "../../../../../../modules-sdk/dist" +import { AdminUpdateUserRequest } from "../validators" + +// Get user +export const GET = async (req: MedusaRequest, res: MedusaResponse) => { + const { id } = req.params + + const moduleService: IUserModuleService = req.scope.resolve( + ModuleRegistrationName.USER + ) + const user = await moduleService.retrieve(id, req.retrieveConfig) + + res.status(200).json({ user }) +} + +// update user +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + const workflow = updateUsersWorkflow(req.scope) + + const input = { + updates: [ + { + id: req.params.id, + ...(req.validatedBody as AdminUpdateUserRequest), + } as UpdateUserDTO, + ], + } + + const { result } = await workflow.run({ input }) + + const [user] = result + + res.status(200).json({ user }) +} + +// delete user +export const DELETE = async (req: MedusaRequest, res: MedusaResponse) => { + const { id } = req.params + const workflow = deleteUsersWorkflow(req.scope) + + const { errors } = await workflow.run({ + input: { ids: [id] }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ + id, + object: "user", + deleted: true, + }) +} diff --git a/packages/medusa/src/api-v2/admin/users/middlewares.ts b/packages/medusa/src/api-v2/admin/users/middlewares.ts new file mode 100644 index 0000000000..3c6e48f952 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/users/middlewares.ts @@ -0,0 +1,39 @@ +import { transformBody, transformQuery } from "../../../api/middlewares" +import { + AdminCreateUserRequest, + AdminGetUsersParams, + AdminGetUsersUserParams, + AdminUpdateUserRequest, +} from "./validators" +import * as QueryConfig from "./query-config" +import { MiddlewareRoute } from "../../../types/middlewares" + +export const adminUserRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: ["GET"], + matcher: "/admin/users", + middlewares: [ + transformQuery(AdminGetUsersParams, QueryConfig.listTransformQueryConfig), + ], + }, + { + method: ["POST"], + matcher: "/admin/users", + middlewares: [transformBody(AdminCreateUserRequest)], + }, + { + method: ["GET"], + matcher: "/admin/users/:id", + middlewares: [ + transformQuery( + AdminGetUsersUserParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/users/:id", + middlewares: [transformBody(AdminUpdateUserRequest)], + }, +] diff --git a/packages/medusa/src/api-v2/admin/users/query-config.ts b/packages/medusa/src/api-v2/admin/users/query-config.ts new file mode 100644 index 0000000000..78840a340b --- /dev/null +++ b/packages/medusa/src/api-v2/admin/users/query-config.ts @@ -0,0 +1,25 @@ +export const defaultAdminUserRelations = [] +export const allowedAdminUserRelations = [] +export const defaultAdminUserFields = [ + "id", + "first_name", + "last_name", + "email", + "avatar_url", + "metadata", + "created_at", + "updated_at", + "deleted_at", +] + +export const retrieveTransformQueryConfig = { + defaultFields: defaultAdminUserFields, + defaultRelations: defaultAdminUserRelations, + allowedRelations: allowedAdminUserRelations, + isList: false, +} + +export const listTransformQueryConfig = { + ...retrieveTransformQueryConfig, + isList: true, +} diff --git a/packages/medusa/src/api-v2/admin/users/route.ts b/packages/medusa/src/api-v2/admin/users/route.ts new file mode 100644 index 0000000000..bb2c98b044 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/users/route.ts @@ -0,0 +1,48 @@ +import { + ContainerRegistrationKeys, + remoteQueryObjectFromString, +} from "@medusajs/utils" +import { MedusaRequest, MedusaResponse } from "../../../types/routing" +import { createUsersWorkflow } from "@medusajs/core-flows" +import { CreateUserDTO } from "@medusajs/types" + +export const GET = async (req: MedusaRequest, res: MedusaResponse) => { + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + + const query = remoteQueryObjectFromString({ + entryPoint: "user", + variables: { + filters: req.filterableFields, + order: req.listConfig.order, + skip: req.listConfig.skip, + take: req.listConfig.take, + }, + fields: req.listConfig.select as string[], + }) + + const { rows: users, metadata } = await remoteQuery({ + ...query, + }) + + res.status(200).json({ + users, + count: metadata.count, + offset: metadata.skip, + limit: metadata.take, + }) +} + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + const workflow = createUsersWorkflow(req.scope) + + const input = { + input: { + users: [req.validatedBody as CreateUserDTO], + }, + } + + const { result } = await workflow.run(input) + + const [user] = result + res.status(200).json({ user }) +} diff --git a/packages/medusa/src/api-v2/admin/users/validators.ts b/packages/medusa/src/api-v2/admin/users/validators.ts new file mode 100644 index 0000000000..22c539db2c --- /dev/null +++ b/packages/medusa/src/api-v2/admin/users/validators.ts @@ -0,0 +1,112 @@ +import { Type } from "class-transformer" +import { IsEmail, IsOptional, IsString, ValidateNested } from "class-validator" +import { + DateComparisonOperator, + FindParams, + extendedFindParamsMixin, +} from "../../../types/common" +import { IsType } from "../../../utils" + +export class AdminGetUsersUserParams extends FindParams {} + +export class AdminGetUsersParams extends extendedFindParamsMixin({ + limit: 50, + offset: 0, +}) { + /** + * IDs to filter users by. + */ + @IsOptional() + @IsType([String, [String]]) + id?: string | string[] + + /** + * The field to sort the data by. By default, the sort order is ascending. To change the order to descending, prefix the field name with `-`. + */ + @IsString() + @IsOptional() + order?: string + + /** + * Date filters to apply on the users' `update_at` date. + */ + @IsOptional() + @ValidateNested() + @Type(() => DateComparisonOperator) + updated_at?: DateComparisonOperator + + /** + * Date filters to apply on the customer users' `created_at` date. + */ + @IsOptional() + @ValidateNested() + @Type(() => DateComparisonOperator) + created_at?: DateComparisonOperator + + /** + * Date filters to apply on the users' `deleted_at` date. + */ + @IsOptional() + @ValidateNested() + @Type(() => DateComparisonOperator) + deleted_at?: DateComparisonOperator + + /** + * Filter to apply on the users' `email` field. + */ + @IsOptional() + @IsString() + email?: string + + /** + * Filter to apply on the users' `first_name` field. + */ + @IsOptional() + @IsString() + first_name?: string + + /** + * Filter to apply on the users' `last_name` field. + */ + @IsOptional() + @IsString() + last_name?: string + + /** + * Comma-separated fields that should be included in the returned users. + */ + @IsOptional() + @IsString() + fields?: string +} + +export class AdminCreateUserRequest { + @IsEmail() + email: string + + @IsOptional() + @IsString() + first_name?: string + + @IsOptional() + @IsString() + last_name?: string + + @IsString() + @IsOptional() + avatar_url: string +} + +export class AdminUpdateUserRequest { + @IsString() + @IsOptional() + first_name?: string + + @IsString() + @IsOptional() + last_name?: string + + @IsString() + @IsOptional() + avatar_url: string +} diff --git a/packages/medusa/src/api-v2/middlewares.ts b/packages/medusa/src/api-v2/middlewares.ts index aea1658b5e..067b63873d 100644 --- a/packages/medusa/src/api-v2/middlewares.ts +++ b/packages/medusa/src/api-v2/middlewares.ts @@ -8,6 +8,7 @@ import { adminWorkflowsExecutionsMiddlewares } from "./admin/workflows-execution import { authRoutesMiddlewares } from "./auth/middlewares" import { storeCartRoutesMiddlewares } from "./store/carts/middlewares" import { storeCustomerRoutesMiddlewares } from "./store/customers/middlewares" +import { adminUserRoutesMiddlewares } from "./admin/users/middlewares" import { storeRegionRoutesMiddlewares } from "./store/regions/middlewares" export const config: MiddlewaresConfig = { @@ -22,5 +23,6 @@ export const config: MiddlewaresConfig = { ...adminWorkflowsExecutionsMiddlewares, ...storeRegionRoutesMiddlewares, ...adminRegionRoutesMiddlewares, + ...adminUserRoutesMiddlewares, ], } diff --git a/packages/types/src/user/common.ts b/packages/types/src/user/common.ts index 0d535d9008..f380879aca 100644 --- a/packages/types/src/user/common.ts +++ b/packages/types/src/user/common.ts @@ -1,5 +1,41 @@ -export type UserDTO = { +import { DateComparisonOperator } from "../common" +import { BaseFilterable } from "../dal" + +export interface UserDTO { id: string + email: string + first_name: string | null + last_name: string | null + avatar_url: string | null + metadata: Record | null + created_at: Date + updated_at: Date + deleted_at: Date | null } -export type FilterableUserProps = {} +export interface FilterableUserProps + extends BaseFilterable { + id?: string | string[] + email?: string | string[] + first_name?: string | string[] + last_name?: string | string[] +} + +export interface InviteDTO { + id: string + email: string + accepted: boolean + token: string + expires_at: Date + metadata: Record | null + created_at: Date + updated_at: Date + deleted_at: Date | null +} + +export interface FilterableInviteProps + extends BaseFilterable { + id?: string | string[] + email?: string | string[] + expires_at?: DateComparisonOperator +} diff --git a/packages/types/src/user/mutations.ts b/packages/types/src/user/mutations.ts index 5e9ced22a3..ad9790f51b 100644 --- a/packages/types/src/user/mutations.ts +++ b/packages/types/src/user/mutations.ts @@ -1,6 +1,22 @@ export interface CreateUserDTO { - id?: string + email: string + first_name?: string | null + last_name?: string | null + avatar_url?: string | null + metadata?: Record | null } -export interface UpdateUserDTO { +export interface UpdateUserDTO extends Partial> { + id: string +} + +export interface CreateInviteDTO { + email: string + accepted?: boolean + token: string + expires_at: Date + metadata?: Record | null +} + +export interface UpdateInviteDTO extends Partial { id: string } diff --git a/packages/types/src/user/service.ts b/packages/types/src/user/service.ts index 5b8ec0bd52..eecfe21a7e 100644 --- a/packages/types/src/user/service.ts +++ b/packages/types/src/user/service.ts @@ -1,9 +1,15 @@ -import { CreateUserDTO, UpdateUserDTO } from "./mutations" -import { FilterableUserProps, UserDTO } from "./common" +import { + CreateInviteDTO, + CreateUserDTO, + UpdateInviteDTO, + UpdateUserDTO, +} from "./mutations" +import { FilterableUserProps, InviteDTO, UserDTO } from "./common" import { Context } from "../shared-context" import { FindConfig } from "../common" import { IModuleService } from "../modules-sdk" +import { RestoreReturn, SoftDeleteReturn } from "../dal" export interface IUserModuleService extends IModuleService { retrieve( @@ -33,4 +39,68 @@ export interface IUserModuleService extends IModuleService { update(data: UpdateUserDTO, sharedContext?: Context): Promise delete(ids: string[], sharedContext?: Context): Promise + + softDelete( + userIds: string[], + config?: SoftDeleteReturn, + sharedContext?: Context + ): Promise | void> + + restore( + userIds: string[], + config?: RestoreReturn, + sharedContext?: Context + ): Promise | void> + + retrieveInvite( + id: string, + config?: FindConfig, + sharedContext?: Context + ): Promise + + listInvites( + filters?: FilterableUserProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + + listAndCountInvites( + filters?: FilterableUserProps, + config?: FindConfig, + sharedContext?: Context + ): Promise<[InviteDTO[], number]> + + createInvites( + data: CreateInviteDTO[], + sharedContext?: Context + ): Promise + + createInvites( + data: CreateInviteDTO, + sharedContext?: Context + ): Promise + + updateInvites( + data: UpdateInviteDTO[], + sharedContext?: Context + ): Promise + + updateInvites( + data: UpdateInviteDTO, + sharedContext?: Context + ): Promise + + deleteInvites(ids: string[], sharedContext?: Context): Promise + + softDeleteInvites( + inviteIds: string[], + config?: SoftDeleteReturn, + sharedContext?: Context + ): Promise | void> + + restoreInvites( + inviteIds: string[], + config?: RestoreReturn, + sharedContext?: Context + ): Promise | void> } diff --git a/packages/types/src/workflow/index.ts b/packages/types/src/workflow/index.ts index 57ba877ed9..5a6447c5c6 100644 --- a/packages/types/src/workflow/index.ts +++ b/packages/types/src/workflow/index.ts @@ -3,3 +3,4 @@ export * as CommonWorkflow from "./common" export * as ProductWorkflow from "./product" export * as InventoryWorkflow from "./inventory" export * as PriceListWorkflow from "./price-list" +export * as UserWorkflow from "./user" diff --git a/packages/types/src/workflow/user/create-user.ts b/packages/types/src/workflow/user/create-user.ts new file mode 100644 index 0000000000..225ac3b864 --- /dev/null +++ b/packages/types/src/workflow/user/create-user.ts @@ -0,0 +1,5 @@ +import { CreateUserDTO } from "../../user" + +export interface CreateUsersWorkflowInputDTO { + users: CreateUserDTO[] +} diff --git a/packages/types/src/workflow/user/delete-user.ts b/packages/types/src/workflow/user/delete-user.ts new file mode 100644 index 0000000000..e101004c8c --- /dev/null +++ b/packages/types/src/workflow/user/delete-user.ts @@ -0,0 +1,3 @@ +export interface DeleteUserWorkflowInput { + ids: string[] +} diff --git a/packages/types/src/workflow/user/index.ts b/packages/types/src/workflow/user/index.ts new file mode 100644 index 0000000000..3c166263d1 --- /dev/null +++ b/packages/types/src/workflow/user/index.ts @@ -0,0 +1,3 @@ +export * from "./create-user" +export * from "./update-user" +export * from "./delete-user" diff --git a/packages/types/src/workflow/user/update-user.ts b/packages/types/src/workflow/user/update-user.ts new file mode 100644 index 0000000000..2c0a6dec45 --- /dev/null +++ b/packages/types/src/workflow/user/update-user.ts @@ -0,0 +1,5 @@ +import { UpdateUserDTO } from "../../user" + +export interface UpdateUsersWorkflowInputDTO { + updates: UpdateUserDTO[] +} diff --git a/packages/user/integration-tests/__fixtures__/invite.ts b/packages/user/integration-tests/__fixtures__/invite.ts new file mode 100644 index 0000000000..6d44d23ca8 --- /dev/null +++ b/packages/user/integration-tests/__fixtures__/invite.ts @@ -0,0 +1,17 @@ +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { Invite } from "@models" +import { CreateInviteDTO } from "../../../types/dist" + +export const createInvites = async ( + manager: SqlEntityManager, + inviteData: (CreateInviteDTO & { id?: string })[] +) => { + const invites: Invite[] = [] + + for (const invite of inviteData) { + const inv = manager.create(Invite, invite) + invites.push(inv) + } + + await manager.persistAndFlush(invites) +} diff --git a/packages/user/integration-tests/__fixtures__/user.ts b/packages/user/integration-tests/__fixtures__/user.ts index 1e7439128c..c1922a2414 100644 --- a/packages/user/integration-tests/__fixtures__/user.ts +++ b/packages/user/integration-tests/__fixtures__/user.ts @@ -1,9 +1,10 @@ import { SqlEntityManager } from "@mikro-orm/postgresql" import { User } from "@models" +import { CreateUserDTO } from "../../../types/dist" export const createUsers = async ( manager: SqlEntityManager, - userData = [{ id: "1" }] + userData: (CreateUserDTO & { id?: string })[] ) => { const users: User[] = [] diff --git a/packages/user/integration-tests/__tests__/services/module/invite.spec.ts b/packages/user/integration-tests/__tests__/services/module/invite.spec.ts new file mode 100644 index 0000000000..81ca859786 --- /dev/null +++ b/packages/user/integration-tests/__tests__/services/module/invite.spec.ts @@ -0,0 +1,192 @@ +import { IUserModuleService } from "@medusajs/types/dist/user" +import { MikroOrmWrapper } from "../../../utils" +import { Modules } from "@medusajs/modules-sdk" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { createInvites } from "../../../__fixtures__/invite" +import { getInitModuleConfig } from "../../../utils/get-init-module-config" +import { initModules } from "medusa-test-utils" + +jest.setTimeout(30000) + +const today = new Date() +const expireDate = new Date(today.setDate(today.getDate() + 10)) + +const defaultInviteData = [ + { + id: "1", + email: "user_1@test.com", + token: "test", + expires_at: expireDate, + }, + { + id: "2", + email: "user_2@test.com", + token: "test", + expires_at: expireDate, + }, +] + +describe("UserModuleService - Invite", () => { + let service: IUserModuleService + let testManager: SqlEntityManager + let shutdownFunc: () => Promise + + beforeAll(async () => { + const initModulesConfig = getInitModuleConfig() + + const { medusaApp, shutdown } = await initModules(initModulesConfig) + + service = medusaApp.modules[Modules.USER] + + shutdownFunc = shutdown + }) + + beforeEach(async () => { + await MikroOrmWrapper.setupDatabase() + testManager = MikroOrmWrapper.forkManager() + }) + + afterEach(async () => { + await MikroOrmWrapper.clearDatabase() + }) + + afterAll(async () => { + await shutdownFunc() + }) + + describe("listInvites", () => { + it("should list invites", async () => { + await createInvites(testManager, defaultInviteData) + + const invites = await service.listInvites() + + expect(invites).toEqual([ + expect.objectContaining({ + id: "1", + }), + expect.objectContaining({ + id: "2", + }), + ]) + }) + + it("should list invites by id", async () => { + await createInvites(testManager, defaultInviteData) + const invites = await service.listInvites({ + id: ["1"], + }) + + expect(invites).toEqual([ + expect.objectContaining({ + id: "1", + }), + ]) + }) + }) + + describe("listAndCountInvites", () => { + it("should list and count invites", async () => { + await createInvites(testManager, defaultInviteData) + const [invites, count] = await service.listAndCountInvites() + + expect(count).toEqual(2) + expect(invites).toEqual([ + expect.objectContaining({ + id: "1", + }), + expect.objectContaining({ + id: "2", + }), + ]) + }) + + it("should listAndCount invites by id", async () => { + await createInvites(testManager, defaultInviteData) + const [invites, count] = await service.listAndCountInvites({ + id: "1", + }) + + expect(count).toEqual(1) + expect(invites).toEqual([ + expect.objectContaining({ + id: "1", + }), + ]) + }) + }) + + describe("retrieveInvite", () => { + const id = "1" + + it("should return an invite for the given id", async () => { + await createInvites(testManager, defaultInviteData) + const invite = await service.retrieveInvite(id) + + expect(invite).toEqual( + expect.objectContaining({ + id, + }) + ) + }) + + it("should throw an error when an invite with the given id does not exist", async () => { + const error = await service + .retrieveInvite("does-not-exist") + .catch((e) => e) + + expect(error.message).toEqual( + "Invite with id: does-not-exist was not found" + ) + }) + + it("should throw an error when inviteId is not provided", async () => { + const error = await service + .retrieveInvite(undefined as unknown as string) + .catch((e) => e) + + expect(error.message).toEqual("invite - id must be defined") + }) + + it("should return invite based on config select param", async () => { + await createInvites(testManager, defaultInviteData) + const invite = await service.retrieveInvite(id, { + select: ["id"], + }) + + expect(invite).toEqual({ + id, + }) + }) + }) + + describe("updateInvite", () => { + it("should throw an error when an id does not exist", async () => { + const error = await service + .updateInvites([ + { + id: "does-not-exist", + }, + ]) + .catch((e) => e) + + expect(error.message).toEqual('Invite with id "does-not-exist" not found') + }) + }) + + describe("createInvitie", () => { + it("should create an invite successfully", async () => { + await service.createInvites(defaultInviteData) + + const [invites, count] = await service.listAndCountInvites({ + id: ["1"], + }) + + expect(count).toEqual(1) + expect(invites[0]).toEqual( + expect.objectContaining({ + id: "1", + }) + ) + }) + }) +}) diff --git a/packages/user/integration-tests/__tests__/services/module/user.spec.ts b/packages/user/integration-tests/__tests__/services/module/user.spec.ts index 654c91cab7..d8ade26a76 100644 --- a/packages/user/integration-tests/__tests__/services/module/user.spec.ts +++ b/packages/user/integration-tests/__tests__/services/module/user.spec.ts @@ -8,6 +8,17 @@ import { initModules } from "medusa-test-utils" jest.setTimeout(30000) +const defaultUserData = [ + { + id: "1", + email: "user_1@test.com", + }, + { + id: "2", + email: "user_2@test.com", + }, +] + describe("UserModuleService - User", () => { let service: IUserModuleService let testManager: SqlEntityManager @@ -26,8 +37,6 @@ describe("UserModuleService - User", () => { beforeEach(async () => { await MikroOrmWrapper.setupDatabase() testManager = MikroOrmWrapper.forkManager() - - await createUsers(testManager) }) afterEach(async () => { @@ -38,19 +47,24 @@ describe("UserModuleService - User", () => { await shutdownFunc() }) - describe("listUsers", () => { + describe("list", () => { it("should list users", async () => { - const users = await service.list() - const serialized = JSON.parse(JSON.stringify(users)) + await createUsers(testManager, defaultUserData) - expect(serialized).toEqual([ + const users = await service.list() + + expect(users).toEqual([ expect.objectContaining({ id: "1", }), + expect.objectContaining({ + id: "2", + }), ]) }) it("should list users by id", async () => { + await createUsers(testManager, defaultUserData) const users = await service.list({ id: ["1"], }) @@ -63,20 +77,24 @@ describe("UserModuleService - User", () => { }) }) - describe("listAndCountUsers", () => { + describe("listAndCount", () => { it("should list and count users", async () => { + await createUsers(testManager, defaultUserData) const [users, count] = await service.listAndCount() - const serialized = JSON.parse(JSON.stringify(users)) - expect(count).toEqual(1) - expect(serialized).toEqual([ + expect(count).toEqual(2) + expect(users).toEqual([ expect.objectContaining({ id: "1", }), + expect.objectContaining({ + id: "2", + }), ]) }) - it("should listAndCount Users by id", async () => { + it("should list and count users by id", async () => { + await createUsers(testManager, defaultUserData) const [Users, count] = await service.listAndCount({ id: "1", }) @@ -90,10 +108,12 @@ describe("UserModuleService - User", () => { }) }) - describe("retrieveUser", () => { + describe("retrieve", () => { const id = "1" it("should return an user for the given id", async () => { + await createUsers(testManager, defaultUserData) + const user = await service.retrieve(id) expect(user).toEqual( @@ -104,13 +124,7 @@ describe("UserModuleService - User", () => { }) it("should throw an error when an user with the given id does not exist", async () => { - let error - - try { - await service.retrieve("does-not-exist") - } catch (e) { - error = e - } + const error = await service.retrieve("does-not-exist").catch((e) => e) expect(error.message).toEqual( "User with id: does-not-exist was not found" @@ -118,18 +132,16 @@ describe("UserModuleService - User", () => { }) it("should throw an error when a userId is not provided", async () => { - let error - - try { - await service.retrieve(undefined as unknown as string) - } catch (e) { - error = e - } + const error = await service + .retrieve(undefined as unknown as string) + .catch((e) => e) expect(error.message).toEqual("user - id must be defined") }) it("should return user based on config select param", async () => { + await createUsers(testManager, defaultUserData) + const User = await service.retrieve(id, { select: ["id"], }) @@ -142,10 +154,12 @@ describe("UserModuleService - User", () => { }) }) - describe("deleteUser", () => { + describe("delete", () => { const id = "1" - it("should delete the Users given an id successfully", async () => { + it("should delete the users given an id successfully", async () => { + await createUsers(testManager, defaultUserData) + await service.delete([id]) const users = await service.list({ @@ -156,40 +170,32 @@ describe("UserModuleService - User", () => { }) }) - describe("updateUser", () => { + describe("update", () => { it("should throw an error when a id does not exist", async () => { - let error - - try { - await service.update([ + const error = await service + .update([ { id: "does-not-exist", }, ]) - } catch (e) { - error = e - } + .catch((e) => e) expect(error.message).toEqual('User with id "does-not-exist" not found') }) }) - describe("createUser", () => { - it("should create a User successfully", async () => { - await service.create([ - { - id: "2", - }, - ]) + describe("create", () => { + it("should create a user successfully", async () => { + await service.create(defaultUserData) const [User, count] = await service.listAndCount({ - id: ["2"], + id: ["1"], }) expect(count).toEqual(1) expect(User[0]).toEqual( expect.objectContaining({ - id: "2", + id: "1", }) ) }) diff --git a/packages/user/mikro-orm.config.dev.ts b/packages/user/mikro-orm.config.dev.ts index 0e43e74c3a..451c0035c4 100644 --- a/packages/user/mikro-orm.config.dev.ts +++ b/packages/user/mikro-orm.config.dev.ts @@ -1,8 +1,12 @@ import * as entities from "./src/models" +import { TSMigrationGenerator } from "@medusajs/utils" module.exports = { entities: Object.values(entities), schema: "public", clientUrl: "postgres://postgres@localhost/medusa-user", type: "postgresql", + migrations: { + generator: TSMigrationGenerator, + }, } diff --git a/packages/user/src/migrations/.snapshot-medusa-user.json b/packages/user/src/migrations/.snapshot-medusa-user.json index 27daf1f82b..ba5c75981b 100644 --- a/packages/user/src/migrations/.snapshot-medusa-user.json +++ b/packages/user/src/migrations/.snapshot-medusa-user.json @@ -14,11 +14,245 @@ "primary": false, "nullable": false, "mappedType": "text" + }, + "email": { + "name": "email", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "accepted": { + "name": "accepted", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "false", + "mappedType": "boolean" + }, + "token": { + "name": "token", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "mappedType": "datetime" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "invite", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_invite_email", + "columnNames": [ + "email" + ], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_invite_email\" ON \"invite\" (email) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_invite_token", + "columnNames": [ + "token" + ], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_invite_token\" ON \"invite\" (token) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_invite_deleted_at", + "columnNames": [ + "deleted_at" + ], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_invite_token\" ON \"invite\" (deleted_at) WHERE deleted_at IS NOT NULL" + }, + { + "keyName": "invite_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {} + }, + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "first_name": { + "name": "first_name", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "last_name": { + "name": "last_name", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "email": { + "name": "email", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" } }, "name": "user", "schema": "public", "indexes": [ + { + "keyName": "IDX_user_email", + "columnNames": [ + "email" + ], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_user_email\" ON \"user\" (email) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_user_deleted_at", + "columnNames": [ + "deleted_at" + ], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_user_deleted_at\" ON \"user\" (deleted_at) WHERE deleted_at IS NOT NULL" + }, { "keyName": "user_pkey", "columnNames": [ diff --git a/packages/user/src/migrations/Migration20240201081925.ts b/packages/user/src/migrations/Migration20240201081925.ts deleted file mode 100644 index eac0e0f06f..0000000000 --- a/packages/user/src/migrations/Migration20240201081925.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Migration } from '@mikro-orm/migrations'; - -export class Migration20240201081925 extends Migration { - - async up(): Promise { - this.addSql('create table "user" ("id" text not null, constraint "user_pkey" primary key ("id"));'); - } - - async down(): Promise { - this.addSql('drop table if exists "user" cascade;'); - } - -} diff --git a/packages/user/src/migrations/Migration20240214033943.ts b/packages/user/src/migrations/Migration20240214033943.ts new file mode 100644 index 0000000000..8ba729b37f --- /dev/null +++ b/packages/user/src/migrations/Migration20240214033943.ts @@ -0,0 +1,53 @@ +import { generatePostgresAlterColummnIfExistStatement } from "@medusajs/utils" +import { Migration } from "@mikro-orm/migrations" + +export class Migration20240214033943 extends Migration { + async up(): Promise { + this.addSql( + 'create table if not exists "invite" ("id" text not null, "email" text not null, "accepted" boolean not null default false, "token" text not null, "expires_at" timestamptz not null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "invite_pkey" primary key ("id"));' + ) + this.addSql( + 'alter table "invite" add column if not exists "email" text not null;' + ) + + this.addSql( + generatePostgresAlterColummnIfExistStatement( + "invite", + ["user_email"], + "DROP NOT NULL" + ) + ) + + this.addSql( + 'CREATE UNIQUE INDEX IF NOT EXISTS "IDX_invite_email" ON "invite" (email) WHERE deleted_at IS NULL;' + ) + + this.addSql( + 'CREATE INDEX IF NOT EXISTS "IDX_invite_token" ON "invite" (token) WHERE deleted_at IS NULL;' + ) + this.addSql( + 'CREATE INDEX IF NOT EXISTS "IDX_invite_deleted_at" ON "invite" (deleted_at) WHERE deleted_at IS NOT NULL;' + ) + + this.addSql( + 'create table if not exists "user" ("id" text not null, "first_name" text null, "last_name" text null, "email" text not null, "avatar_url" text null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "user_pkey" primary key ("id"));' + ) + + this.addSql( + 'alter table "user" add column if not exists "avatar_url" text null;' + ) + + this.addSql( + 'CREATE INDEX IF NOT EXISTS "IDX_user_email" ON "user" (email) WHERE deleted_at IS NULL;' + ) + this.addSql( + 'CREATE INDEX IF NOT EXISTS "IDX_user_deleted_at" ON "user" (deleted_at) WHERE deleted_at IS NOT NULL;' + ) + } + + async down(): Promise { + this.addSql('drop table if exists "invite" cascade;') + + this.addSql('drop table if exists "user" cascade;') + } +} diff --git a/packages/user/src/models/index.ts b/packages/user/src/models/index.ts index 028bc24218..2ef84f37ca 100644 --- a/packages/user/src/models/index.ts +++ b/packages/user/src/models/index.ts @@ -1 +1,2 @@ export { default as User } from "./user" +export { default as Invite } from "./invite" diff --git a/packages/user/src/models/invite.ts b/packages/user/src/models/invite.ts new file mode 100644 index 0000000000..de8ba28243 --- /dev/null +++ b/packages/user/src/models/invite.ts @@ -0,0 +1,110 @@ +import { + BeforeCreate, + Entity, + OnInit, + PrimaryKey, + Property, + Index, + Filter, + OptionalProps, +} from "@mikro-orm/core" + +import { + DALUtils, + generateEntityId, + createPsqlIndexStatementHelper, +} from "@medusajs/utils" +import { DAL } from "@medusajs/types" + +const inviteEmailIndexName = "IDX_invite_email" +const inviteEmailIndexStatement = createPsqlIndexStatementHelper({ + name: inviteEmailIndexName, + tableName: "invite", + columns: "email", + where: "deleted_at IS NULL", + unique: true, +}) + +const inviteTokenIndexName = "IDX_invite_token" +const inviteTokenIndexStatement = createPsqlIndexStatementHelper({ + name: inviteTokenIndexName, + tableName: "invite", + columns: "token", + where: "deleted_at IS NULL", +}) + +const inviteDeletedAtIndexName = "IDX_invite_deleted_at" +const inviteDeletedAtIndexStatement = createPsqlIndexStatementHelper({ + name: inviteDeletedAtIndexName, + tableName: "invite", + columns: "deleted_at", + where: "deleted_at IS NOT NULL", +}) + +type OptionalFields = + | "metadata" + | "accepted" + | DAL.SoftDeletableEntityDateColumns +@Entity({ tableName: "invite" }) +@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions) +export default class Invite { + [OptionalProps]: OptionalFields + + @PrimaryKey({ columnType: "text" }) + id: string + + @Index({ + name: inviteEmailIndexName, + expression: inviteEmailIndexStatement, + }) + @Property({ columnType: "text" }) + email: string + + @Property({ columnType: "boolean" }) + accepted: boolean = false + + @Index({ + name: inviteTokenIndexName, + expression: inviteTokenIndexStatement, + }) + @Property({ columnType: "text" }) + token: string + + @Property({ columnType: "timestamptz" }) + expires_at: Date + + @Property({ columnType: "jsonb", nullable: true }) + metadata: Record | null = null + + @Property({ + onCreate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + created_at: Date + + @Property({ + onCreate: () => new Date(), + onUpdate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + updated_at: Date + + @Index({ + name: inviteDeletedAtIndexName, + expression: inviteDeletedAtIndexStatement, + }) + @Property({ columnType: "timestamptz", nullable: true }) + deleted_at: Date | null = null + + @OnInit() + onInit() { + this.id = generateEntityId(this.id, "invite") + } + + @BeforeCreate() + beforeCreate() { + this.id = generateEntityId(this.id, "invite") + } +} diff --git a/packages/user/src/models/user.ts b/packages/user/src/models/user.ts index 7b1ad592f1..c31d7c0d47 100644 --- a/packages/user/src/models/user.ts +++ b/packages/user/src/models/user.ts @@ -3,16 +3,88 @@ import { Entity, OnInit, PrimaryKey, + Property, + Index, + OptionalProps, + Filter, } from "@mikro-orm/core" -import { generateEntityId } from "@medusajs/utils" +import { DALUtils, generateEntityId } from "@medusajs/utils" +import { DAL } from "@medusajs/types" +import { createPsqlIndexStatementHelper } from "@medusajs/utils" +const userEmailIndexName = "IDX_user_email" +const userEmailIndexStatement = createPsqlIndexStatementHelper({ + name: userEmailIndexName, + tableName: "user", + columns: "email", + where: "deleted_at IS NULL", +}) + +const userDeletedAtIndexName = "IDX_user_deleted_at" +const userDeletedAtIndexStatement = createPsqlIndexStatementHelper({ + name: userDeletedAtIndexName, + tableName: "user", + columns: "deleted_at", + where: "deleted_at IS NOT NULL", +}) + +type OptionalFields = + | "first_name" + | "last_name" + | "metadata" + | "avatar_url" + | DAL.SoftDeletableEntityDateColumns @Entity() +@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions) export default class User { + [OptionalProps]?: OptionalFields + @PrimaryKey({ columnType: "text" }) id!: string + @Property({ columnType: "text", nullable: true }) + first_name: string | null = null + + @Property({ columnType: "text", nullable: true }) + last_name: string | null = null + + @Property({ columnType: "text" }) + @Index({ + name: userEmailIndexName, + expression: userEmailIndexStatement, + }) + email: string + + @Property({ columnType: "text", nullable: true }) + avatar_url: string | null = null + + @Property({ columnType: "jsonb", nullable: true }) + metadata: Record | null = null + + @Property({ + onCreate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + created_at: Date + + @Property({ + onCreate: () => new Date(), + onUpdate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + updated_at: Date + + @Index({ + name: userDeletedAtIndexName, + expression: userDeletedAtIndexStatement, + }) + @Property({ columnType: "timestamptz", nullable: true }) + deleted_at?: Date | null = null + @BeforeCreate() onCreate() { this.id = generateEntityId(this.id, "user") diff --git a/packages/user/src/services/user-module.ts b/packages/user/src/services/user-module.ts index 4baab519f8..1da5005e25 100644 --- a/packages/user/src/services/user-module.ts +++ b/packages/user/src/services/user-module.ts @@ -4,9 +4,6 @@ import { InternalModuleDeclaration, ModuleJoinerConfig, UserTypes, - CreateUserDTO, - UpdateUserDTO, - UserDTO, ModulesSdkTypes, } from "@medusajs/types" import { @@ -17,20 +14,28 @@ import { } from "@medusajs/utils" import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config" -import { User } from "@models" +import { Invite, User } from "@models" type InjectedDependencies = { baseRepository: DAL.RepositoryService userService: ModulesSdkTypes.InternalModuleService + inviteService: ModulesSdkTypes.InternalModuleService } -const generateMethodForModels = [] +const generateMethodForModels = [Invite] -export default class UserModuleService +export default class UserModuleService< + TUser extends User = User, + TInvite extends Invite = Invite + > extends ModulesSdkUtils.abstractModuleServiceFactory< InjectedDependencies, - UserDTO, - {} + UserTypes.UserDTO, + { + Invite: { + dto: UserTypes.InviteDTO + } + } >(User, generateMethodForModels, entityNameToLinkableKeysMap) implements UserTypes.IUserModuleService { @@ -41,9 +46,10 @@ export default class UserModuleService protected baseRepository_: DAL.RepositoryService protected readonly userService_: ModulesSdkTypes.InternalModuleService + protected readonly inviteService_: ModulesSdkTypes.InternalModuleService constructor( - { userService, baseRepository }: InjectedDependencies, + { userService, inviteService, baseRepository }: InjectedDependencies, protected readonly moduleDeclaration: InternalModuleDeclaration ) { // @ts-ignore @@ -51,22 +57,29 @@ export default class UserModuleService this.baseRepository_ = baseRepository this.userService_ = userService + this.inviteService_ = inviteService } - create(data: CreateUserDTO[], sharedContext?: Context): Promise - create(data: CreateUserDTO, sharedContext?: Context): Promise + create( + data: UserTypes.CreateUserDTO[], + sharedContext?: Context + ): Promise + create( + data: UserTypes.CreateUserDTO, + sharedContext?: Context + ): Promise - @InjectManager("baseRepository_") + @InjectTransactionManager("baseRepository_") async create( - data: CreateUserDTO[] | CreateUserDTO, + data: UserTypes.CreateUserDTO[] | UserTypes.CreateUserDTO, @MedusaContext() sharedContext: Context = {} ): Promise { const input = Array.isArray(data) ? data : [data] - const users = await this.create_(input, sharedContext) + const users = await this.userService_.create(input, sharedContext) const serializedUsers = await this.baseRepository_.serialize< - UserTypes.UserDTO[] + UserTypes.UserDTO[] | UserTypes.UserDTO >(users, { populate: true, }) @@ -74,25 +87,23 @@ export default class UserModuleService return Array.isArray(data) ? serializedUsers : serializedUsers[0] } + update( + data: UserTypes.UpdateUserDTO[], + sharedContext?: Context + ): Promise + update( + data: UserTypes.UpdateUserDTO, + sharedContext?: Context + ): Promise + @InjectTransactionManager("baseRepository_") - protected async create_( - data: CreateUserDTO[], - @MedusaContext() sharedContext: Context - ): Promise { - return await this.userService_.create(data, sharedContext) - } - - update(data: UpdateUserDTO[], sharedContext?: Context): Promise - update(data: UpdateUserDTO, sharedContext?: Context): Promise - - @InjectManager("baseRepository_") async update( - data: UpdateUserDTO | UpdateUserDTO[], + data: UserTypes.UpdateUserDTO | UserTypes.UpdateUserDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { const input = Array.isArray(data) ? data : [data] - const updatedUsers = await this.update_(input, sharedContext) + const updatedUsers = await this.userService_.update(input, sharedContext) const serializedUsers = await this.baseRepository_.serialize< UserTypes.UserDTO[] @@ -103,11 +114,60 @@ export default class UserModuleService return Array.isArray(data) ? serializedUsers : serializedUsers[0] } + createInvites( + data: UserTypes.CreateInviteDTO[], + sharedContext?: Context + ): Promise + createInvites( + data: UserTypes.CreateInviteDTO, + sharedContext?: Context + ): Promise + @InjectTransactionManager("baseRepository_") - protected async update_( - data: UpdateUserDTO[], - @MedusaContext() sharedContext: Context - ): Promise { - return await this.userService_.update(data, sharedContext) + async createInvites( + data: UserTypes.CreateInviteDTO[] | UserTypes.CreateInviteDTO, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const input = Array.isArray(data) ? data : [data] + + const invites = await this.inviteService_.create(input, sharedContext) + + const serializedInvites = await this.baseRepository_.serialize< + UserTypes.InviteDTO[] | UserTypes.InviteDTO + >(invites, { + populate: true, + }) + + return Array.isArray(data) ? serializedInvites : serializedInvites[0] + } + + updateInvites( + data: UserTypes.UpdateInviteDTO[], + sharedContext?: Context + ): Promise + updateInvites( + data: UserTypes.UpdateInviteDTO, + sharedContext?: Context + ): Promise + + @InjectTransactionManager("baseRepository_") + async updateInvites( + data: UserTypes.UpdateInviteDTO | UserTypes.UpdateInviteDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const input = Array.isArray(data) ? data : [data] + + const updatedInvites = await this.inviteService_.update( + input, + sharedContext + ) + + const serializedInvites = await this.baseRepository_.serialize< + UserTypes.InviteDTO[] + >(updatedInvites, { + populate: true, + }) + + return Array.isArray(data) ? serializedInvites : serializedInvites[0] } } diff --git a/yarn.lock b/yarn.lock index 2048341339..e0a090f86c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8806,7 +8806,7 @@ __metadata: languageName: unknown linkType: soft -"@medusajs/user@workspace:packages/user": +"@medusajs/user@workspace:^, @medusajs/user@workspace:packages/user": version: 0.0.0-use.local resolution: "@medusajs/user@workspace:packages/user" dependencies: @@ -31576,6 +31576,7 @@ __metadata: "@medusajs/promotion": "workspace:^" "@medusajs/region": "workspace:^" "@medusajs/types": "workspace:^" + "@medusajs/user": "workspace:^" "@medusajs/utils": "workspace:^" "@medusajs/workflow-engine-inmemory": "workspace:*" babel-preset-medusa-package: "*"