From d68089b2aa2fb4ab52640424ed1a378cd649364f Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Wed, 24 Jan 2024 10:41:35 +0100 Subject: [PATCH] fix(medusa): Implement listAndCount for UserService and update list endpoint (#6190) --- .changeset/lucky-snails-mix.md | 9 + .../admin/__snapshots__/user.js.snap | 90 --------- integration-tests/api/__tests__/admin/user.js | 163 +++++++++++----- .../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 | 74 +++++-- .../admin/users/__tests__/list-users.js | 11 +- .../src/api/routes/admin/users/index.ts | 27 ++- .../src/api/routes/admin/users/list-users.ts | 184 +++++++++++++++++- .../medusa/src/services/__mocks__/user.js | 18 +- packages/medusa/src/services/user.ts | 90 ++++++++- packages/medusa/src/types/user.ts | 1 + 13 files changed, 633 insertions(+), 198 deletions(-) create mode 100644 .changeset/lucky-snails-mix.md delete mode 100644 integration-tests/api/__tests__/admin/__snapshots__/user.js.snap 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 new file mode 100644 index 0000000000..03d5437627 --- /dev/null +++ b/.changeset/lucky-snails-mix.md @@ -0,0 +1,9 @@ +--- +"@medusajs/medusa": patch +"@medusajs/medusa-js": patch +"medusa-react": patch +--- + +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/integration-tests/api/__tests__/admin/__snapshots__/user.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/user.js.snap deleted file mode 100644 index f960505d33..0000000000 --- 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 32c10332da..03fb2ff84e 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 () => { @@ -75,25 +77,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()) + } }) }) @@ -138,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 () => { @@ -159,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", () => { @@ -417,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/generated/client-types/src/lib/models/AdminGetUsersParams.ts b/packages/generated/client-types/src/lib/models/AdminGetUsersParams.ts new file mode 100644 index 0000000000..d41bd6bf31 --- /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 3ac5ab4a56..af37b1c8e8 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 826c247130..cef40037b9 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 f2e0e8cd8e..6f13a86a7b 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,23 +42,60 @@ 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 Users + * ``` + * * @customNamespace Hooks.Admin.Users * @category Queries */ export const useAdminUsers = ( + query?: AdminGetUsersParams, options?: UseQueryOptionsWrapper< Response, Error, - ReturnType + ReturnType > ) => { const { client } = useMedusa() const { data, ...rest } = useQuery( - adminUserKeys.lists(), - () => client.admin.users.list(), + adminUserKeys.list(query), + () => client.admin.users.list(query), options ) return { ...data, ...rest } as const @@ -59,20 +103,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 +124,9 @@ export const useAdminUsers = ( *
* ) * } - * + * * export default User - * + * * @customNamespace Hooks.Admin.Users * @category Queries */ 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 61dd5b1aec..d59991d8a2 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/api/routes/admin/users/index.ts b/packages/medusa/src/api/routes/admin/users/index.ts index 867a572ec8..d84f22bff3 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 b25a4ba3f4..28de81b022 100644 --- a/packages/medusa/src/api/routes/admin/users/list-users.ts +++ b/packages/medusa/src/api/routes/admin/users/list-users.ts @@ -1,13 +1,100 @@ +import { Type } from "class-transformer" +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" /** * @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 @@ -16,7 +103,7 @@ import UserService from "../../../../services/user" * 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 @@ -75,9 +162,96 @@ import UserService from "../../../../services/user" * "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 }) +} + +/** + * Parameters used to filter and configure the pagination of the retrieved users. + */ +export class AdminGetUsersParams extends extendedFindParamsMixin() { + /** + * IDs to filter users by. + */ + @IsOptional() + @IsType([String, [String]]) + id?: string | string[] + + /** + * Search terms to search users' first name, last name, and email. + */ + @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 + + /** + * 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 + + /** + * Filter to apply on the users' `role` field. + */ + @IsOptional() + @IsEnum(UserRole, { each: true }) + role?: UserRole } diff --git a/packages/medusa/src/services/__mocks__/user.js b/packages/medusa/src/services/__mocks__/user.js index 192fb3d9fb..9f3ac121c0 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"), })) diff --git a/packages/medusa/src/services/user.ts b/packages/medusa/src/services/user.ts index 8a72cf2384..d654e8c577 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,86 @@ 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 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.find(query) + } + + async listAndCount( + selector: Selector & { q?: string } = {}, + config: FindConfig = { skip: 0, take: 20 } + ) { + 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.findAndCount(query) } /** diff --git a/packages/medusa/src/types/user.ts b/packages/medusa/src/types/user.ts index f0359794d0..64e6391f56 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"