From 3275488e566d178a3507302bb1be998820b958d1 Mon Sep 17 00:00:00 2001 From: Kasper Date: Tue, 23 Jan 2024 10:33:39 +0100 Subject: [PATCH 01/11] init work --- .../src/api/routes/admin/users/list-users.ts | 49 ++++++++++++++++ packages/medusa/src/services/user.ts | 57 +++++++++++++++++-- packages/medusa/src/types/user.ts | 1 + 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/packages/medusa/src/api/routes/admin/users/list-users.ts b/packages/medusa/src/api/routes/admin/users/list-users.ts index b25a4ba3f4888..ebc0ea2b8bcd1 100644 --- a/packages/medusa/src/api/routes/admin/users/list-users.ts +++ b/packages/medusa/src/api/routes/admin/users/list-users.ts @@ -1,4 +1,11 @@ +import { Type } from "class-transformer" +import { IsOptional, IsString, ValidateNested } from "class-validator" import UserService from "../../../../services/user" +import { + DateComparisonOperator, + extendedFindParamsMixin, +} from "../../../../types/common" +import { IsType } from "../../../../utils" /** * @oas [get] /admin/users @@ -81,3 +88,45 @@ export default async (req, res) => { res.status(200).json({ users }) } + +export class AdminGetUsersParams extends extendedFindParamsMixin({ + limit: 20, + offset: 0, +}) { + /** + * IDs to filter inventory items by. + */ + @IsOptional() + @IsType([String, [String]]) + id?: string | string[] + + /** + * Search terms to search inventory items' sku, title, and description. + */ + @IsOptional() + @IsString() + q?: 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 +} diff --git a/packages/medusa/src/services/user.ts b/packages/medusa/src/services/user.ts index 8a72cf2384150..696a6129c4803 100644 --- a/packages/medusa/src/services/user.ts +++ b/packages/medusa/src/services/user.ts @@ -1,17 +1,18 @@ +import { Selector } from "@medusajs/types" import { FlagRouter } from "@medusajs/utils" import jwt from "jsonwebtoken" import { isDefined, MedusaError } from "medusa-core-utils" import Scrypt from "scrypt-kdf" -import { EntityManager } from "typeorm" +import { EntityManager, FindOptionsWhere, ILike } 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" import { - CreateUserInput, - FilterableUserProps, - UpdateUserInput, + CreateUserInput, + FilterableUserProps, + UpdateUserInput, } from "../types/user" import { buildQuery, setMetadata } from "../utils" import { validateEmail } from "../utils/is-email" @@ -62,9 +63,53 @@ class UserService extends TransactionBaseService { * @param {Object} config - the configuration object for the query * @return {Promise} the result of the find operation */ - async list(selector: FilterableUserProps, config = {}): Promise { + async list( + selector: Selector & { q?: string } = {}, + config: FindConfig = { skip: 0, take: 20 } + ): Promise { + const [users] = await this.listAndCount(selector, config) + return users + } + + async listAndCount( + selector: Selector & { q?: string } = {}, + config: FindConfig = { skip: 0, take: 20 } + ) { const userRepo = this.activeManager_.withRepository(this.userRepository_) - return await userRepo.find(buildQuery(selector, config)) + + let q: string | undefined + + if (selector.q) { + q = selector.q + delete selector.q + } + + const query = buildQuery(selector, config) + + if (q) { + const where = query.where as FindOptionsWhere + + delete where.email + delete where.first_name + delete where.last_name + + query.where = [ + { + ...where, + email: ILike(`%${q}%`), + }, + { + ...where, + first_name: ILike(`%${q}%`), + }, + { + ...where, + last_name: ILike(`%${q}%`), + }, + ] + } + + return await userRepo.findAndCount(query) } /** diff --git a/packages/medusa/src/types/user.ts b/packages/medusa/src/types/user.ts index f0359794d005c..64e6391f56a31 100644 --- a/packages/medusa/src/types/user.ts +++ b/packages/medusa/src/types/user.ts @@ -32,6 +32,7 @@ export type FilterableUserProps = PartialPick< | "email" | "first_name" | "last_name" + | "role" | "created_at" | "updated_at" | "deleted_at" From cc522f5278029750928e2d63c56a98a6fc10fd3b Mon Sep 17 00:00:00 2001 From: Kasper Date: Tue, 23 Jan 2024 16:59:51 +0100 Subject: [PATCH 02/11] add tests --- integration-tests/api/__tests__/admin/user.js | 87 ++++++++++++++----- .../src/api/routes/admin/users/index.ts | 27 +++++- .../src/api/routes/admin/users/list-users.ts | 35 +++++--- 3 files changed, 116 insertions(+), 33 deletions(-) diff --git a/integration-tests/api/__tests__/admin/user.js b/integration-tests/api/__tests__/admin/user.js index 32c10332da306..2a12b2b2ec0ad 100644 --- a/integration-tests/api/__tests__/admin/user.js +++ b/integration-tests/api/__tests__/admin/user.js @@ -28,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: true }) }) afterAll(async () => { @@ -75,25 +75,72 @@ describe("/admin/users", () => { expect(response.status).toEqual(200) - expect(response.data.users).toMatchSnapshot([ - { - id: "admin_user", - email: "admin@medusa.js", - api_token: "test_token", - role: "admin", - created_at: expect.any(String), - updated_at: expect.any(String), - }, - { - id: "member-user", - role: "member", - email: "member@test.com", - first_name: "member", - last_name: "user", - created_at: expect.any(String), - updated_at: expect.any(String), - }, - ]) + expect(response.data.users).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "admin_user", + email: "admin@medusa.js", + api_token: "test_token", + role: "admin", + created_at: expect.any(String), + updated_at: expect.any(String), + }), + expect.objectContaining({ + id: "member-user", + role: "member", + email: "member@test.com", + first_name: "member", + last_name: "user", + created_at: expect.any(String), + updated_at: expect.any(String), + }), + ]) + ) + }) + + it("lists users that match the free text search", async () => { + const api = useApi() + + const response = await api.get("/admin/users?q=member", adminReqConfig) + + expect(response.status).toEqual(200) + + expect(response.data.users.length).toEqual(1) + expect(response.data.users).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "member-user", + role: "member", + email: "member@test.com", + first_name: "member", + last_name: "user", + created_at: expect.any(String), + updated_at: expect.any(String), + }), + ]) + ) + }) + + it("orders users by created_at", async () => { + const api = useApi() + + const response = await api.get( + "/admin/users?order=created_at", + adminReqConfig + ) + + expect(response.status).toEqual(200) + expect(response.data.users.length).toBeGreaterThan(0) + + for (let i = 0; i < response.data.users.length - 1; i++) { + const user1 = response.data.users[i] + const user2 = response.data.users[i + 1] + + const date1 = new Date(user1.created_at) + const date2 = new Date(user2.created_at) + + expect(date1.getTime()).toBeLessThanOrEqual(date2.getTime()) + } }) }) diff --git a/packages/medusa/src/api/routes/admin/users/index.ts b/packages/medusa/src/api/routes/admin/users/index.ts index 867a572ec816c..d84f22bff31be 100644 --- a/packages/medusa/src/api/routes/admin/users/index.ts +++ b/packages/medusa/src/api/routes/admin/users/index.ts @@ -1,7 +1,8 @@ import { Router } from "express" -import { User } from "../../../.." +import { User } from "../../../../models/user" import { DeleteResponse } from "../../../../types/common" -import middlewares from "../../../middlewares" +import middlewares, { transformQuery } from "../../../middlewares" +import { AdminGetUsersParams } from "./list-users" export const unauthenticatedUserRoutes = (app) => { const route = Router() @@ -30,11 +31,31 @@ export default (app) => { route.delete("/:user_id", middlewares.wrap(require("./delete-user").default)) - route.get("/", middlewares.wrap(require("./list-users").default)) + route.get( + "/", + transformQuery(AdminGetUsersParams, { + defaultFields: defaultAdminUserFields, + isList: true, + }), + middlewares.wrap(require("./list-users").default) + ) return app } +export const defaultAdminUserFields: (keyof User)[] = [ + "id", + "email", + "first_name", + "last_name", + "role", + "api_token", + "created_at", + "updated_at", + "deleted_at", + "metadata", +] + /** * @schema AdminUserRes * type: object diff --git a/packages/medusa/src/api/routes/admin/users/list-users.ts b/packages/medusa/src/api/routes/admin/users/list-users.ts index ebc0ea2b8bcd1..595046390ca0b 100644 --- a/packages/medusa/src/api/routes/admin/users/list-users.ts +++ b/packages/medusa/src/api/routes/admin/users/list-users.ts @@ -1,10 +1,12 @@ import { Type } from "class-transformer" -import { IsOptional, IsString, ValidateNested } from "class-validator" +import { IsEnum, IsOptional, IsString, ValidateNested } from "class-validator" +import { Request, Response } from "express" import UserService from "../../../../services/user" import { DateComparisonOperator, extendedFindParamsMixin, } from "../../../../types/common" +import { UserRole } from "../../../../types/user" import { IsType } from "../../../../utils" /** @@ -82,26 +84,32 @@ import { IsType } from "../../../../utils" * "500": * $ref: "#/components/responses/500_error" */ -export default async (req, res) => { +export default async (req: Request, res: Response) => { const userService: UserService = req.scope.resolve("userService") - const users = await userService.list({}) - res.status(200).json({ users }) + const listConfig = req.listConfig + const filterableFields = req.filterableFields + + const [users, count] = await userService.listAndCount( + filterableFields, + listConfig + ) + + res + .status(200) + .json({ users, count, offset: listConfig.skip, limit: listConfig.take }) } -export class AdminGetUsersParams extends extendedFindParamsMixin({ - limit: 20, - offset: 0, -}) { +export class AdminGetUsersParams extends extendedFindParamsMixin() { /** - * IDs to filter inventory items by. + * IDs to filter users by. */ @IsOptional() @IsType([String, [String]]) id?: string | string[] /** - * Search terms to search inventory items' sku, title, and description. + * Search terms to search users' first name, last name, and email. */ @IsOptional() @IsString() @@ -129,4 +137,11 @@ export class AdminGetUsersParams extends extendedFindParamsMixin({ @ValidateNested() @Type(() => DateComparisonOperator) created_at?: DateComparisonOperator + + /** + * Filter to apply on the users' `role` field. + */ + @IsOptional() + @IsEnum(UserRole, { each: true }) + role?: UserRole } From cdcb4498558d06980439b176cf0696ec8714342f Mon Sep 17 00:00:00 2001 From: Kasper Date: Tue, 23 Jan 2024 17:04:47 +0100 Subject: [PATCH 03/11] add changeset --- .changeset/lucky-snails-mix.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/lucky-snails-mix.md diff --git a/.changeset/lucky-snails-mix.md b/.changeset/lucky-snails-mix.md new file mode 100644 index 0000000000000..cb3f8571c749f --- /dev/null +++ b/.changeset/lucky-snails-mix.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +fix(medusa): Implements `listAndCount` method for UserService, and updates list endpoint to accept the expected params From 3ebcd50f2f35d529156417566bbe0db14c137f14 Mon Sep 17 00:00:00 2001 From: Kasper Date: Tue, 23 Jan 2024 17:11:50 +0100 Subject: [PATCH 04/11] update oas --- .../src/api/routes/admin/users/list-users.ts | 106 +++++++++++++++++- 1 file changed, 104 insertions(+), 2 deletions(-) diff --git a/packages/medusa/src/api/routes/admin/users/list-users.ts b/packages/medusa/src/api/routes/admin/users/list-users.ts index 595046390ca0b..2394facf29407 100644 --- a/packages/medusa/src/api/routes/admin/users/list-users.ts +++ b/packages/medusa/src/api/routes/admin/users/list-users.ts @@ -13,10 +13,88 @@ import { IsType } from "../../../../utils" * @oas [get] /admin/users * operationId: "GetUsers" * summary: "List Users" - * description: "Retrieve all admin users." + * description: "Retrieves a list of users. The users can be filtered by fields such as `q` or `email`. The users can also be sorted or paginated." * x-authenticated: true + * parameters: + * - (query) id {string} Filter by a user ID. + * - (query) email {string} Filter by email. + * - (query) first_name {string} Filter by first name. + * - (query) last_name {string} Filter by last name. + * - (query) q {string} term used to search users' first name, last name, and email. + * - (query) order {string} A user field to sort-order the retrieved users by. + * - in: query + * name: created_at + * description: Filter by a creation date range. + * schema: + * type: object + * properties: + * lt: + * type: string + * description: filter by dates less than this date + * format: date + * gt: + * type: string + * description: filter by dates greater than this date + * format: date + * lte: + * type: string + * description: filter by dates less than or equal to this date + * format: date + * gte: + * type: string + * description: filter by dates greater than or equal to this date + * format: date + * - in: query + * name: updated_at + * description: Filter by an update date range. + * schema: + * type: object + * properties: + * lt: + * type: string + * description: filter by dates less than this date + * format: date + * gt: + * type: string + * description: filter by dates greater than this date + * format: date + * lte: + * type: string + * description: filter by dates less than or equal to this date + * format: date + * gte: + * type: string + * description: filter by dates greater than or equal to this date + * format: date + * - in: query + * name: deleted_at + * description: Filter by a deletion date range. + * schema: + * type: object + * properties: + * lt: + * type: string + * description: filter by dates less than this date + * format: date + * gt: + * type: string + * description: filter by dates greater than this date + * format: date + * lte: + * type: string + * description: filter by dates less than or equal to this date + * format: date + * gte: + * type: string + * description: filter by dates greater than or equal to this date + * format: date + * - (query) offset=0 {integer} The number of users to skip when retrieving the users. + * - (query) limit=20 {integer} Limit the number of users returned. + * - (query) expand {string} Comma-separated relations that should be expanded in the returned users. + * - (query) fields {string} Comma-separated fields that should be included in the returned users. * x-codegen: * method: list + * queryParams: AdminGetUsersParams * x-codeSamples: * - lang: JavaScript * label: JS Client @@ -25,7 +103,7 @@ import { IsType } from "../../../../utils" * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) * // must be previously logged in or use api token * medusa.admin.users.list() - * .then(({ users }) => { + * .then(({ users, limit, offset, count }) => { * console.log(users.length); * }) * - lang: tsx @@ -100,6 +178,9 @@ export default async (req: Request, res: Response) => { .json({ users, count, offset: listConfig.skip, limit: listConfig.take }) } +/** + * Parameters used to filter and configure the pagination of the retrieved users. + */ export class AdminGetUsersParams extends extendedFindParamsMixin() { /** * IDs to filter users by. @@ -138,6 +219,27 @@ export class AdminGetUsersParams extends extendedFindParamsMixin() { @Type(() => DateComparisonOperator) created_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 + /** * Filter to apply on the users' `role` field. */ From f85af6cefa5f9121109816e15caf24c7805666ab Mon Sep 17 00:00:00 2001 From: Kasper Date: Tue, 23 Jan 2024 17:12:51 +0100 Subject: [PATCH 05/11] add deleted_at filter --- packages/medusa/src/api/routes/admin/users/list-users.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/medusa/src/api/routes/admin/users/list-users.ts b/packages/medusa/src/api/routes/admin/users/list-users.ts index 2394facf29407..28de81b0222fa 100644 --- a/packages/medusa/src/api/routes/admin/users/list-users.ts +++ b/packages/medusa/src/api/routes/admin/users/list-users.ts @@ -219,6 +219,14 @@ export class AdminGetUsersParams extends extendedFindParamsMixin() { @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. */ From 7eb8e888e14cbf4b03c3b959c0005ee120808fd8 Mon Sep 17 00:00:00 2001 From: Kasper Date: Tue, 23 Jan 2024 17:34:13 +0100 Subject: [PATCH 06/11] fix js and react and update changeset --- .changeset/lucky-snails-mix.md | 6 +- .../src/lib/models/AdminGetUsersParams.ts | 110 ++++++++++++++++++ .../client-types/src/lib/models/index.ts | 1 + .../medusa-js/src/resources/admin/users.ts | 53 ++++++--- .../src/hooks/admin/users/queries.ts | 69 ++++++++--- 5 files changed, 211 insertions(+), 28 deletions(-) create mode 100644 packages/generated/client-types/src/lib/models/AdminGetUsersParams.ts diff --git a/.changeset/lucky-snails-mix.md b/.changeset/lucky-snails-mix.md index cb3f8571c749f..1debaa471ef53 100644 --- a/.changeset/lucky-snails-mix.md +++ b/.changeset/lucky-snails-mix.md @@ -1,5 +1,9 @@ --- "@medusajs/medusa": patch +"@medusajs/medusa-js": patch +"@medusajs/medusa-react": patch --- -fix(medusa): Implements `listAndCount` method for UserService, and updates list endpoint to accept the expected params +fix(medusa): Implements `listAndCount` method for UserService, and updates list endpoint to accept the expected params. +fix(medusa-js): Update `admin.users.list` to accept query params. +fix(medusa-react): Update `useAdminUsers` hook to accept query params. diff --git a/packages/generated/client-types/src/lib/models/AdminGetUsersParams.ts b/packages/generated/client-types/src/lib/models/AdminGetUsersParams.ts new file mode 100644 index 0000000000000..d41bd6bf31abf --- /dev/null +++ b/packages/generated/client-types/src/lib/models/AdminGetUsersParams.ts @@ -0,0 +1,110 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import { SetRelation, Merge } from "../core/ModelUtils" + +export interface AdminGetUsersParams { + /** + * Filter by a user ID. + */ + id?: string + /** + * Filter by email. + */ + email?: string + /** + * Filter by first name. + */ + first_name?: string + /** + * Filter by last name. + */ + last_name?: string + /** + * term used to search users' first name, last name, and email. + */ + q?: string + /** + * A user field to sort-order the retrieved users by. + */ + order?: string + /** + * Filter by a creation date range. + */ + created_at?: { + /** + * filter by dates less than this date + */ + lt?: string + /** + * filter by dates greater than this date + */ + gt?: string + /** + * filter by dates less than or equal to this date + */ + lte?: string + /** + * filter by dates greater than or equal to this date + */ + gte?: string + } + /** + * Filter by an update date range. + */ + updated_at?: { + /** + * filter by dates less than this date + */ + lt?: string + /** + * filter by dates greater than this date + */ + gt?: string + /** + * filter by dates less than or equal to this date + */ + lte?: string + /** + * filter by dates greater than or equal to this date + */ + gte?: string + } + /** + * Filter by a deletion date range. + */ + deleted_at?: { + /** + * filter by dates less than this date + */ + lt?: string + /** + * filter by dates greater than this date + */ + gt?: string + /** + * filter by dates less than or equal to this date + */ + lte?: string + /** + * filter by dates greater than or equal to this date + */ + gte?: string + } + /** + * The number of users to skip when retrieving the users. + */ + offset?: number + /** + * Limit the number of users returned. + */ + limit?: number + /** + * Comma-separated relations that should be expanded in the returned users. + */ + expand?: string + /** + * Comma-separated fields that should be included in the returned users. + */ + fields?: string +} diff --git a/packages/generated/client-types/src/lib/models/index.ts b/packages/generated/client-types/src/lib/models/index.ts index 3ac5ab4a56fee..af37b1c8e84b4 100644 --- a/packages/generated/client-types/src/lib/models/index.ts +++ b/packages/generated/client-types/src/lib/models/index.ts @@ -92,6 +92,7 @@ export type { AdminGetStockLocationsParams } from "./AdminGetStockLocationsParam export type { AdminGetSwapsParams } from "./AdminGetSwapsParams" export type { AdminGetTaxRatesParams } from "./AdminGetTaxRatesParams" export type { AdminGetTaxRatesTaxRateParams } from "./AdminGetTaxRatesTaxRateParams" +export type { AdminGetUsersParams } from "./AdminGetUsersParams" export type { AdminGetVariantParams } from "./AdminGetVariantParams" export type { AdminGetVariantsParams } from "./AdminGetVariantsParams" export type { AdminGetVariantsVariantInventoryRes } from "./AdminGetVariantsVariantInventoryRes" diff --git a/packages/medusa-js/src/resources/admin/users.ts b/packages/medusa-js/src/resources/admin/users.ts index 826c247130272..cef40037b9de4 100644 --- a/packages/medusa-js/src/resources/admin/users.ts +++ b/packages/medusa-js/src/resources/admin/users.ts @@ -1,37 +1,38 @@ import { AdminDeleteUserRes, + AdminGetUsersParams, AdminResetPasswordRequest, AdminResetPasswordTokenRequest, AdminUserRes, AdminUsersListRes, } from "@medusajs/medusa" +import qs from "qs" import { - ResponsePromise, AdminCreateUserPayload, AdminUpdateUserPayload, + ResponsePromise, } from "../.." import BaseResource from "../base" /** * This class is used to send requests to [Admin User API Routes](https://docs.medusajs.com/api/admin#users). All its method * are available in the JS Client under the `medusa.admin.users` property. - * + * * All methods in this class require {@link AdminAuthResource.createSession | user authentication}. - * + * * A store can have more than one user, each having the same privileges. Admins can manage users, their passwords, and more. - * + * * Related Guide: [How to manage users](https://docs.medusajs.com/modules/users/admin/manage-users). */ class AdminUsersResource extends BaseResource { - /** * Generate a password token for an admin user with a given email. This also triggers the `user.password_reset` event. So, if you have a Notification Service installed - * that can handle this event, a notification, such as an email, will be sent to the user. The token is triggered as part of the `user.password_reset` event's payload. + * that can handle this event, a notification, such as an email, will be sent to the user. The token is triggered as part of the `user.password_reset` event's payload. * That token must be used later to reset the password using the {@link resetPassword} method. * @param {AdminResetPasswordTokenRequest} payload - The user's reset details. * @param {Record} customHeaders - Custom headers to attach to the request. * @returns {ResponsePromise} Resolves when the token is generated successfully. - * + * * @example * import Medusa from "@medusajs/medusa-js" * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) @@ -60,7 +61,7 @@ class AdminUsersResource extends BaseResource { * @param {AdminResetPasswordRequest} payload - The reset details. * @param {Record} customHeaders - Custom headers to attach to the request. * @returns {ResponsePromise} Resolves to the user's details. - * + * * @example * import Medusa from "@medusajs/medusa-js" * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) @@ -86,7 +87,7 @@ class AdminUsersResource extends BaseResource { * @param {string} id - The user's ID. * @param {Record} customHeaders - Custom headers to attach to the request. * @returns {ResponsePromise} Resolves to the user's details. - * + * * @example * import Medusa from "@medusajs/medusa-js" * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) @@ -109,7 +110,7 @@ class AdminUsersResource extends BaseResource { * @param {AdminCreateUserPayload} payload - The user to create. * @param {Record} customHeaders - Custom headers to attach to the request. * @returns {ResponsePromise} Resolves to the user's details. - * + * * @example * import Medusa from "@medusajs/medusa-js" * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) @@ -136,7 +137,7 @@ class AdminUsersResource extends BaseResource { * @param {AdminUpdateUserPayload} payload - The attributes to update in the user. * @param {Record} customHeaders - Custom headers to attach to the request. * @returns {ResponsePromise} Resolves to the user's details. - * + * * @example * import Medusa from "@medusajs/medusa-js" * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) @@ -162,7 +163,7 @@ class AdminUsersResource extends BaseResource { * @param {string} id - The user's ID. * @param {Record} customHeaders - Custom headers to attach to the request. * @returns {ResponsePromise} Resolves to the deletion operation's details. - * + * * @example * import Medusa from "@medusajs/medusa-js" * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) @@ -184,8 +185,10 @@ class AdminUsersResource extends BaseResource { * Retrieve all admin users. * @param {Record} customHeaders - Custom headers to attach to the request. * @returns {ResponsePromise} Resolves to the list of users. - * + * * @example + * To list users: + * * import Medusa from "@medusajs/medusa-js" * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) * // must be previously logged in or use api token @@ -193,11 +196,33 @@ class AdminUsersResource extends BaseResource { * .then(({ users }) => { * console.log(users.length); * }) + * + * By default, only the first `20` users are returned. You can control pagination by specifying the `limit` and `offset` properties: + * + * ```ts + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.users.list({ + * limit, + * offset + * }) + * .then(({ users, limit, offset, count }) => { + * console.log(users.length); + * }) + * ``` */ list( + query?: AdminGetUsersParams, customHeaders: Record = {} ): ResponsePromise { - const path = `/admin/users` + let path = `/admin/users` + + if (query) { + const queryString = qs.stringify(query) + path += `?${queryString}` + } + return this.client.request("GET", path, undefined, {}, customHeaders) } } diff --git a/packages/medusa-react/src/hooks/admin/users/queries.ts b/packages/medusa-react/src/hooks/admin/users/queries.ts index f2e0e8cd8e89a..0537a5367a134 100644 --- a/packages/medusa-react/src/hooks/admin/users/queries.ts +++ b/packages/medusa-react/src/hooks/admin/users/queries.ts @@ -1,4 +1,8 @@ -import { AdminUserRes, AdminUsersListRes } from "@medusajs/medusa" +import { + AdminGetUsersParams, + AdminUserRes, + AdminUsersListRes, +} from "@medusajs/medusa" import { Response } from "@medusajs/medusa-js" import { useQuery } from "@tanstack/react-query" import { useMedusa } from "../../../contexts" @@ -13,14 +17,17 @@ type UserQueryKeys = typeof adminUserKeys /** * This hook retrieves all admin users. - * + * * @example + * To list users: + * + * ```tsx * import React from "react" * import { useAdminUsers } from "medusa-react" - * + * * const Users = () => { * const { users, isLoading } = useAdminUsers() - * + * * return ( *
* {isLoading && Loading...} @@ -35,13 +42,49 @@ type UserQueryKeys = typeof adminUserKeys *
* ) * } - * + * * export default Users - * + * ``` + * + * By default, only the first `20` records are retrieved. You can control pagination by specifying the `limit` and `offset` properties: + * + * ```tsx + * import React from "react" + * import { useAdminUsers } from "medusa-react" + * + * const Users = () => { + * const { + * users, + * limit, + * offset, + * isLoading + * } = useAdminUsers({ + * limit: 20, + * offset: 0 + * }) + * + * return ( + *
+ * {isLoading && Loading...} + * {users && !users.length && No Users} + * {users && users.length > 0 && ( + *
    + * {users.map((user) => ( + *
  • {user.email}
  • + * ))} + *
+ * )} + *
+ * ) + * } + * + * export default Products + * * @customNamespace Hooks.Admin.Users * @category Queries */ export const useAdminUsers = ( + query?: AdminGetUsersParams, options?: UseQueryOptionsWrapper< Response, Error, @@ -51,7 +94,7 @@ export const useAdminUsers = ( const { client } = useMedusa() const { data, ...rest } = useQuery( adminUserKeys.lists(), - () => client.admin.users.list(), + () => client.admin.users.list(query), options ) return { ...data, ...rest } as const @@ -59,20 +102,20 @@ export const useAdminUsers = ( /** * This hook retrieves an admin user's details. - * + * * @example * import React from "react" * import { useAdminUser } from "medusa-react" - * + * * type Props = { * userId: string * } - * + * * const User = ({ userId }: Props) => { * const { user, isLoading } = useAdminUser( * userId * ) - * + * * return ( *
* {isLoading && Loading...} @@ -80,9 +123,9 @@ export const useAdminUsers = ( *
* ) * } - * + * * export default User - * + * * @customNamespace Hooks.Admin.Users * @category Queries */ From 2b5d7c2aa043ded7db09a2a7f9c9ab434a35757f Mon Sep 17 00:00:00 2001 From: Kasper Date: Tue, 23 Jan 2024 17:37:17 +0100 Subject: [PATCH 07/11] fix hook keys --- packages/medusa-react/src/hooks/admin/users/queries.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/medusa-react/src/hooks/admin/users/queries.ts b/packages/medusa-react/src/hooks/admin/users/queries.ts index 0537a5367a134..6f13a86a7b38d 100644 --- a/packages/medusa-react/src/hooks/admin/users/queries.ts +++ b/packages/medusa-react/src/hooks/admin/users/queries.ts @@ -78,7 +78,8 @@ type UserQueryKeys = typeof adminUserKeys * ) * } * - * export default Products + * export default Users + * ``` * * @customNamespace Hooks.Admin.Users * @category Queries @@ -88,12 +89,12 @@ export const useAdminUsers = ( options?: UseQueryOptionsWrapper< Response, Error, - ReturnType + ReturnType > ) => { const { client } = useMedusa() const { data, ...rest } = useQuery( - adminUserKeys.lists(), + adminUserKeys.list(query), () => client.admin.users.list(query), options ) From 93c8cd235299fb6f904646009a0795c4235a064a Mon Sep 17 00:00:00 2001 From: Kasper Date: Tue, 23 Jan 2024 17:38:36 +0100 Subject: [PATCH 08/11] cleanup --- integration-tests/api/__tests__/admin/user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-tests/api/__tests__/admin/user.js b/integration-tests/api/__tests__/admin/user.js index 2a12b2b2ec0ad..331d333ad1132 100644 --- a/integration-tests/api/__tests__/admin/user.js +++ b/integration-tests/api/__tests__/admin/user.js @@ -28,7 +28,7 @@ describe("/admin/users", () => { 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 () => { From 4cea5b5ed023045b9c99411793c563b4158f495e Mon Sep 17 00:00:00 2001 From: Kasper Date: Tue, 23 Jan 2024 18:14:36 +0100 Subject: [PATCH 09/11] fix test --- .../routes/admin/users/__tests__/list-users.js | 11 +++++++++-- packages/medusa/src/services/__mocks__/user.js | 18 +++++++++--------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/medusa/src/api/routes/admin/users/__tests__/list-users.js b/packages/medusa/src/api/routes/admin/users/__tests__/list-users.js index 61dd5b1aecaa8..d59991d8a247d 100644 --- a/packages/medusa/src/api/routes/admin/users/__tests__/list-users.js +++ b/packages/medusa/src/api/routes/admin/users/__tests__/list-users.js @@ -21,8 +21,15 @@ describe("GET /admin/users", () => { }) it("calls service retrieve", () => { - expect(UserServiceMock.list).toHaveBeenCalledTimes(1) - expect(UserServiceMock.list).toHaveBeenCalledWith({}) + expect(UserServiceMock.listAndCount).toHaveBeenCalledTimes(1) + expect(UserServiceMock.listAndCount).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + order: { created_at: "DESC" }, + skip: 0, + take: 20, + }) + ) }) }) }) diff --git a/packages/medusa/src/services/__mocks__/user.js b/packages/medusa/src/services/__mocks__/user.js index 192fb3d9fb253..9f3ac121c0f68 100644 --- a/packages/medusa/src/services/__mocks__/user.js +++ b/packages/medusa/src/services/__mocks__/user.js @@ -1,6 +1,5 @@ -import Scrypt from "scrypt-kdf" import { IdMap } from "medusa-test-utils" -import _ from "lodash" +import Scrypt from "scrypt-kdf" export const users = { testUser: { @@ -29,7 +28,7 @@ export const UserServiceMock = { withTransaction: function () { return this }, - create: jest.fn().mockImplementation(data => { + create: jest.fn().mockImplementation((data) => { if (data.email === "oliver@test.dk") { return Promise.resolve(users.testUser) } @@ -37,7 +36,8 @@ export const UserServiceMock = { }), update: jest.fn().mockReturnValue(Promise.resolve()), list: jest.fn().mockReturnValue(Promise.resolve([])), - delete: jest.fn().mockImplementation(data => { + listAndCount: jest.fn().mockReturnValue(Promise.resolve([[], 0])), + delete: jest.fn().mockImplementation((data) => { if (data === IdMap.getId("delete-user")) { return Promise.resolve({ id: IdMap.getId("delete-user"), @@ -47,7 +47,7 @@ export const UserServiceMock = { } return Promise.resolve(undefined) }), - retrieve: jest.fn().mockImplementation(userId => { + retrieve: jest.fn().mockImplementation((userId) => { if (userId === IdMap.getId("test-user")) { return Promise.resolve(users.testUser) } @@ -60,7 +60,7 @@ export const UserServiceMock = { } return Promise.resolve(undefined) }), - setPassword_: jest.fn().mockImplementation(userId => { + setPassword_: jest.fn().mockImplementation((userId) => { if (userId === IdMap.getId("test-user")) { return Promise.resolve(users.testUser) } @@ -80,13 +80,13 @@ export const UserServiceMock = { generateResetPasswordToken: jest .fn() .mockReturnValue(Promise.resolve("JSONWEBTOKEN")), - retrieveByApiToken: jest.fn().mockImplementation(token => { + retrieveByApiToken: jest.fn().mockImplementation((token) => { if (token === "123456789") { return Promise.resolve(users.user1) } return Promise.resolve(undefined) }), - retrieveByEmail: jest.fn().mockImplementation(email => { + retrieveByEmail: jest.fn().mockImplementation((email) => { if (email === "vandijk@test.dk") { return Promise.resolve({ id: IdMap.getId("vandijk"), @@ -95,7 +95,7 @@ export const UserServiceMock = { }) } if (email === "oliver@test.dk") { - return Scrypt.kdf("123456789", { logN: 1, r: 1, p: 1 }).then(hash => ({ + return Scrypt.kdf("123456789", { logN: 1, r: 1, p: 1 }).then((hash) => ({ email, password_hash: hash.toString("base64"), })) From 69c3ec3242acbfd003d948a2e6bc2f0b1185c78e Mon Sep 17 00:00:00 2001 From: Kasper Date: Tue, 23 Jan 2024 18:47:33 +0100 Subject: [PATCH 10/11] fix changeset --- .changeset/lucky-snails-mix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/lucky-snails-mix.md b/.changeset/lucky-snails-mix.md index 1debaa471ef53..03d5437627c0b 100644 --- a/.changeset/lucky-snails-mix.md +++ b/.changeset/lucky-snails-mix.md @@ -1,7 +1,7 @@ --- "@medusajs/medusa": patch "@medusajs/medusa-js": patch -"@medusajs/medusa-react": patch +"medusa-react": patch --- fix(medusa): Implements `listAndCount` method for UserService, and updates list endpoint to accept the expected params. From b73b2fe2ba890cf7629c96be1f27eb8fe1cf0389 Mon Sep 17 00:00:00 2001 From: Kasper Date: Wed, 24 Jan 2024 09:58:04 +0100 Subject: [PATCH 11/11] update list method and remove last snapshots from users tests --- .../admin/__snapshots__/user.js.snap | 90 ------------------- integration-tests/api/__tests__/admin/user.js | 78 ++++++++-------- packages/medusa/src/services/user.ts | 37 +++++++- 3 files changed, 78 insertions(+), 127 deletions(-) delete mode 100644 integration-tests/api/__tests__/admin/__snapshots__/user.js.snap diff --git a/integration-tests/api/__tests__/admin/__snapshots__/user.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/user.js.snap deleted file mode 100644 index f960505d3361e..0000000000000 --- a/integration-tests/api/__tests__/admin/__snapshots__/user.js.snap +++ /dev/null @@ -1,90 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`/admin/users GET /admin/users lists users 1`] = ` -Array [ - Object { - "api_token": "test_token", - "created_at": Any, - "deleted_at": null, - "email": "admin@medusa.js", - "first_name": null, - "id": "admin_user", - "last_name": null, - "metadata": null, - "role": "admin", - "updated_at": Any, - }, - Object { - "api_token": null, - "created_at": Any, - "deleted_at": null, - "email": "member@test.com", - "first_name": "member", - "id": "member-user", - "last_name": "user", - "metadata": null, - "role": "member", - "updated_at": Any, - }, -] -`; - -exports[`/admin/users GET /admin/users returns user by id 1`] = ` -Object { - "api_token": "test_token", - "created_at": Any, - "deleted_at": null, - "email": "admin@medusa.js", - "first_name": null, - "id": "admin_user", - "last_name": null, - "metadata": null, - "role": "admin", - "updated_at": Any, -} -`; - -exports[`/admin/users POST /admin/users creates a user 1`] = ` -Object { - "api_token": null, - "created_at": Any, - "deleted_at": null, - "email": "test@test123.com", - "first_name": null, - "id": StringMatching /\\^usr_\\*/, - "last_name": null, - "metadata": null, - "role": "member", - "updated_at": Any, -} -`; - -exports[`/admin/users POST /admin/users updates a user 1`] = ` -Object { - "api_token": null, - "created_at": Any, - "deleted_at": null, - "email": "member@test.com", - "first_name": "karl", - "id": "member-user", - "last_name": "user", - "metadata": null, - "password_hash": null, - "role": "member", - "updated_at": Any, -} -`; - -exports[`[MEDUSA_FF_ANALYTICS] /admin/analytics-config DELETE /admin/users Deletes a user and their analytics config 1`] = ` -Array [ - Object { - "anonymize": false, - "created_at": Any, - "deleted_at": Any, - "id": Any, - "opt_out": false, - "updated_at": Any, - "user_id": "member-user", - }, -] -`; diff --git a/integration-tests/api/__tests__/admin/user.js b/integration-tests/api/__tests__/admin/user.js index 331d333ad1132..03fb2ff84e337 100644 --- a/integration-tests/api/__tests__/admin/user.js +++ b/integration-tests/api/__tests__/admin/user.js @@ -54,14 +54,16 @@ describe("/admin/users", () => { const response = await api.get("/admin/users/admin_user", adminReqConfig) expect(response.status).toEqual(200) - expect(response.data.user).toMatchSnapshot({ - id: "admin_user", - email: "admin@medusa.js", - api_token: "test_token", - role: "admin", - created_at: expect.any(String), - updated_at: expect.any(String), - }) + expect(response.data.user).toEqual( + expect.objectContaining({ + id: "admin_user", + email: "admin@medusa.js", + api_token: "test_token", + role: "admin", + created_at: expect.any(String), + updated_at: expect.any(String), + }) + ) }) it("lists users", async () => { @@ -185,13 +187,15 @@ describe("/admin/users", () => { .catch((err) => console.log(err)) expect(response.status).toEqual(200) - expect(response.data.user).toMatchSnapshot({ - id: expect.stringMatching(/^usr_*/), - created_at: expect.any(String), - updated_at: expect.any(String), - role: "member", - email: "test@test123.com", - }) + expect(response.data.user).toEqual( + expect.objectContaining({ + id: expect.stringMatching(/^usr_*/), + created_at: expect.any(String), + updated_at: expect.any(String), + role: "member", + email: "test@test123.com", + }) + ) }) it("updates a user", async () => { @@ -206,15 +210,17 @@ describe("/admin/users", () => { .catch((err) => console.log(err.response.data.message)) expect(updateResponse.status).toEqual(200) - expect(updateResponse.data.user).toMatchSnapshot({ - id: "member-user", - created_at: expect.any(String), - updated_at: expect.any(String), - role: "member", - email: "member@test.com", - first_name: "karl", - last_name: "user", - }) + expect(updateResponse.data.user).toEqual( + expect.objectContaining({ + id: "member-user", + created_at: expect.any(String), + updated_at: expect.any(String), + role: "member", + email: "member@test.com", + first_name: "karl", + last_name: "user", + }) + ) }) describe("Password reset", () => { @@ -464,17 +470,19 @@ describe("[MEDUSA_FF_ANALYTICS] /admin/analytics-config", () => { `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, - }, - ]) + expect(configs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + created_at: expect.any(Date), + updated_at: expect.any(Date), + deleted_at: expect.any(Date), + id: expect.any(String), + user_id: userId, + opt_out: false, + anonymize: false, + }), + ]) + ) }) }) }) diff --git a/packages/medusa/src/services/user.ts b/packages/medusa/src/services/user.ts index 696a6129c4803..d654e8c57712e 100644 --- a/packages/medusa/src/services/user.ts +++ b/packages/medusa/src/services/user.ts @@ -67,8 +67,41 @@ class UserService extends TransactionBaseService { selector: Selector & { q?: string } = {}, config: FindConfig = { skip: 0, take: 20 } ): Promise { - const [users] = await this.listAndCount(selector, config) - return users + const userRepo = this.activeManager_.withRepository(this.userRepository_) + + let q: string | undefined + + if (selector.q) { + q = selector.q + delete selector.q + } + + const query = buildQuery(selector, config) + + if (q) { + const where = query.where as FindOptionsWhere + + delete where.email + delete where.first_name + delete where.last_name + + query.where = [ + { + ...where, + email: ILike(`%${q}%`), + }, + { + ...where, + first_name: ILike(`%${q}%`), + }, + { + ...where, + last_name: ILike(`%${q}%`), + }, + ] + } + + return await userRepo.find(query) } async listAndCount(