Feat(inventory-next, medusa): Add List inventory items endpoint (#6694)
* code for list inventory items * fix integration tests * move integration test * calculate reserved and stocked quantities in db * update lockfile
This commit is contained in:
1 parent
3dd55efd15
commit
c3f8c30ba6
14 files changed
+1247
-16
No files matched your search
@@ -3,7 +3,9 @@ import {
|
||||
Collection,
|
||||
Entity,
|
||||
Filter,
|
||||
Formula,
|
||||
OnInit,
|
||||
OnLoad,
|
||||
OneToMany,
|
||||
OptionalProps,
|
||||
PrimaryKey,
|
||||
@@ -106,7 +108,21 @@ export class InventoryItem {
|
||||
() => InventoryLevel,
|
||||
(inventoryLevel) => inventoryLevel.inventory_item
|
||||
)
|
||||
inventory_levels = new Collection<InventoryLevel>(this)
|
||||
location_levels = new Collection<InventoryLevel>(this)
|
||||
|
||||
@Formula(
|
||||
(item) =>
|
||||
`(SELECT SUM(reserved_quantity) FROM inventory_level il WHERE il.inventory_item_id = ${item}.id)`,
|
||||
{ lazy: true, serializer: Number, hidden: true }
|
||||
)
|
||||
reserved_quantity: number
|
||||
|
||||
@Formula(
|
||||
(item) =>
|
||||
`(SELECT SUM(stocked_quantity) FROM inventory_level il WHERE il.inventory_item_id = ${item}.id)`,
|
||||
{ lazy: true, serializer: Number, hidden: true }
|
||||
)
|
||||
stocked_quantity: number
|
||||
|
||||
@BeforeCreate()
|
||||
private beforeCreate(): void {
|
||||
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
Filter,
|
||||
ManyToOne,
|
||||
OnInit,
|
||||
OnLoad,
|
||||
PrimaryKey,
|
||||
Property,
|
||||
} from "@mikro-orm/core"
|
||||
import { DALUtils, isDefined } from "@medusajs/utils"
|
||||
|
||||
import { DALUtils } from "@medusajs/utils"
|
||||
import { InventoryItem } from "./inventory-item"
|
||||
import { createPsqlIndexStatementHelper } from "@medusajs/utils"
|
||||
import { generateEntityId } from "@medusajs/utils"
|
||||
@@ -91,6 +92,8 @@ export class InventoryLevel {
|
||||
})
|
||||
inventory_item: InventoryItem
|
||||
|
||||
available_quantity: number | null = null
|
||||
|
||||
@BeforeCreate()
|
||||
private beforeCreate(): void {
|
||||
this.id = generateEntityId(this.id, "ilev")
|
||||
@@ -101,4 +104,11 @@ export class InventoryLevel {
|
||||
private onInit(): void {
|
||||
this.id = generateEntityId(this.id, "ilev")
|
||||
}
|
||||
|
||||
@OnLoad()
|
||||
private onLoad(): void {
|
||||
if (isDefined(this.stocked_quantity) && isDefined(this.reserved_quantity)) {
|
||||
this.available_quantity = this.stocked_quantity - this.reserved_quantity
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./inventory-level"
|
||||
export * from "./inventory-item"
|
||||
export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils"
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Context, DAL } from "@medusajs/types"
|
||||
import { InventoryItem, InventoryLevel } from "@models"
|
||||
|
||||
import { SqlEntityManager } from "@mikro-orm/postgresql"
|
||||
import { mikroOrmBaseRepositoryFactory } from "@medusajs/utils"
|
||||
|
||||
export class InventoryItemRepository extends mikroOrmBaseRepositoryFactory<InventoryItem>(
|
||||
InventoryItem
|
||||
) {
|
||||
async find(
|
||||
findOptions: DAL.FindOptions<InventoryItem & { q?: string }> = {
|
||||
where: {},
|
||||
},
|
||||
context: Context = {}
|
||||
): Promise<InventoryItem[]> {
|
||||
const findOptions_ = { ...findOptions }
|
||||
findOptions_.options ??= {}
|
||||
|
||||
this.applyFreeTextSearchFilters<InventoryItem>(
|
||||
findOptions_,
|
||||
this.getFreeTextSearchConstraints
|
||||
)
|
||||
|
||||
return await super.find(findOptions_, context)
|
||||
}
|
||||
|
||||
async findAndCount(
|
||||
findOptions: DAL.FindOptions<InventoryItem & { q?: string }> = {
|
||||
where: {},
|
||||
},
|
||||
context: Context = {}
|
||||
): Promise<[InventoryItem[], number]> {
|
||||
const findOptions_ = { ...findOptions }
|
||||
findOptions_.options ??= {}
|
||||
|
||||
this.applyFreeTextSearchFilters<InventoryItem>(
|
||||
findOptions_,
|
||||
this.getFreeTextSearchConstraints
|
||||
)
|
||||
|
||||
return await super.findAndCount(findOptions_, context)
|
||||
}
|
||||
|
||||
protected getFreeTextSearchConstraints(q: string) {
|
||||
return [
|
||||
{
|
||||
description: {
|
||||
$ilike: `%${q}%`,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: {
|
||||
$ilike: `%${q}%`,
|
||||
},
|
||||
},
|
||||
{
|
||||
sku: {
|
||||
$ilike: `%${q}%`,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,10 @@ import {
|
||||
InjectManager,
|
||||
InjectTransactionManager,
|
||||
InventoryEvents,
|
||||
isDefined,
|
||||
MedusaContext,
|
||||
MedusaError,
|
||||
ModulesSdkUtils,
|
||||
isDefined,
|
||||
partitionArray,
|
||||
} from "@medusajs/utils"
|
||||
import { InventoryItem, InventoryLevel, ReservationItem } from "@models"
|
||||
@@ -100,7 +100,7 @@ export default class InventoryModuleService<
|
||||
{ location_id: string; inventory_item_id: string }[]
|
||||
]
|
||||
|
||||
const inventoryLevels = await this.inventoryLevelService_.list(
|
||||
const inventoryLevels = await this.listInventoryLevels(
|
||||
{
|
||||
$or: [
|
||||
{ id: idData.filter(({ id }) => !!id).map((e) => e.id) },
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as QueryConfig from "./query-config"
|
||||
|
||||
import {
|
||||
AdminGetInventoryItemsItemParams,
|
||||
AdminGetInventoryItemsParams,
|
||||
AdminPostInventoryItemsItemLocationLevelsReq,
|
||||
AdminPostInventoryItemsReq,
|
||||
} from "./validators"
|
||||
import { transformBody, transformQuery } from "../../../api/middlewares"
|
||||
|
||||
import { MiddlewareRoute } from "../../../types/middlewares"
|
||||
import { authenticate } from "../../../utils/authenticate-middleware"
|
||||
|
||||
export const adminInventoryRoutesMiddlewares: MiddlewareRoute[] = [
|
||||
{
|
||||
method: "ALL",
|
||||
matcher: "/admin/inventory-items*",
|
||||
middlewares: [authenticate("admin", ["session", "bearer"])],
|
||||
},
|
||||
{
|
||||
method: ["GET"],
|
||||
matcher: "/admin/inventory-items",
|
||||
middlewares: [
|
||||
transformQuery(
|
||||
AdminGetInventoryItemsParams,
|
||||
QueryConfig.listTransformQueryConfig
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
method: ["POST"],
|
||||
matcher: "/admin/inventory-items/:id/location-levels",
|
||||
middlewares: [transformBody(AdminPostInventoryItemsItemLocationLevelsReq)],
|
||||
},
|
||||
{
|
||||
method: ["POST"],
|
||||
matcher: "/admin/inventory-items",
|
||||
middlewares: [transformBody(AdminPostInventoryItemsReq)],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
import { InventoryNext } from "@medusajs/types"
|
||||
|
||||
export const defaultAdminInventoryItemRelations = []
|
||||
export const allowedAdminInventoryItemRelations = []
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
export const defaultAdminLocationLevelFields: (keyof InventoryNext.InventoryLevelDTO)[] =
|
||||
[
|
||||
"id",
|
||||
"inventory_item_id",
|
||||
"location_id",
|
||||
"stocked_quantity",
|
||||
"reserved_quantity",
|
||||
"incoming_quantity",
|
||||
"available_quantity",
|
||||
"metadata",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
export const defaultAdminInventoryItemFields = [
|
||||
"id",
|
||||
"sku",
|
||||
"title",
|
||||
"description",
|
||||
"thumbnail",
|
||||
"origin_country",
|
||||
"hs_code",
|
||||
"requires_shipping",
|
||||
"mid_code",
|
||||
"material",
|
||||
"weight",
|
||||
"length",
|
||||
"height",
|
||||
"width",
|
||||
"metadata",
|
||||
"reserved_quantity",
|
||||
"stocked_quantity",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
...defaultAdminLocationLevelFields.map(
|
||||
(field) => `location_levels.${field.toString()}`
|
||||
),
|
||||
]
|
||||
|
||||
export const retrieveTransformQueryConfig = {
|
||||
defaultFields: defaultAdminInventoryItemFields,
|
||||
defaultRelations: defaultAdminInventoryItemRelations,
|
||||
allowedRelations: allowedAdminInventoryItemRelations,
|
||||
isList: false,
|
||||
}
|
||||
|
||||
export const listTransformQueryConfig = {
|
||||
...retrieveTransformQueryConfig,
|
||||
isList: true,
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
AuthenticatedMedusaRequest,
|
||||
MedusaResponse,
|
||||
} from "../../../types/routing"
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
remoteQueryObjectFromString,
|
||||
} from "@medusajs/utils"
|
||||
|
||||
// List inventory-items
|
||||
export const GET = async (
|
||||
req: AuthenticatedMedusaRequest,
|
||||
res: MedusaResponse
|
||||
) => {
|
||||
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
||||
|
||||
const query = remoteQueryObjectFromString({
|
||||
entryPoint: "inventory_items",
|
||||
variables: {
|
||||
filters: req.filterableFields,
|
||||
order: req.listConfig.order,
|
||||
skip: req.listConfig.skip,
|
||||
take: req.listConfig.take,
|
||||
},
|
||||
fields: [...(req.listConfig.select as string[])],
|
||||
})
|
||||
|
||||
const { rows: inventory_items, metadata } = await remoteQuery({
|
||||
...query,
|
||||
})
|
||||
|
||||
res.status(200).json({
|
||||
inventory_items,
|
||||
count: metadata.count,
|
||||
offset: metadata.skip,
|
||||
limit: metadata.take,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
DateComparisonOperator,
|
||||
FindParams,
|
||||
NumericalComparisonOperator,
|
||||
StringComparisonOperator,
|
||||
extendedFindParamsMixin,
|
||||
} from "../../../types/common"
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator"
|
||||
import { Transform, Type } from "class-transformer"
|
||||
|
||||
import { IsType } from "../../../utils"
|
||||
|
||||
export class AdminGetInventoryItemsItemParams extends FindParams {}
|
||||
|
||||
/**
|
||||
* Parameters used to filter and configure the pagination of the retrieved inventory items.
|
||||
*/
|
||||
export class AdminGetInventoryItemsParams 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
|
||||
|
||||
/**
|
||||
* Location IDs to filter inventory items by.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsType([String, [String]])
|
||||
location_id?: string | string[]
|
||||
|
||||
/**
|
||||
* SKUs to filter inventory items by.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsType([String, [String]])
|
||||
sku?: string | string[]
|
||||
|
||||
/**
|
||||
* Origin countries to filter inventory items by.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsType([String, [String]])
|
||||
origin_country?: string | string[]
|
||||
|
||||
/**
|
||||
* MID codes to filter inventory items by.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsType([String, [String]])
|
||||
mid_code?: string | string[]
|
||||
|
||||
/**
|
||||
* Materials to filter inventory items by.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsType([String, [String]])
|
||||
material?: string | string[]
|
||||
|
||||
/**
|
||||
* String filters to apply to inventory items' `hs_code` field.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsType([String, [String], StringComparisonOperator])
|
||||
hs_code?: string | string[] | StringComparisonOperator
|
||||
|
||||
/**
|
||||
* Number filters to apply to inventory items' `weight` field.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsType([Number, NumericalComparisonOperator])
|
||||
weight?: number | NumericalComparisonOperator
|
||||
|
||||
/**
|
||||
* Number filters to apply to inventory items' `length` field.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsType([Number, NumericalComparisonOperator])
|
||||
length?: number | NumericalComparisonOperator
|
||||
|
||||
/**
|
||||
* Number filters to apply to inventory items' `height` field.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsType([Number, NumericalComparisonOperator])
|
||||
height?: number | NumericalComparisonOperator
|
||||
|
||||
/**
|
||||
* Number filters to apply to inventory items' `width` field.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsType([Number, NumericalComparisonOperator])
|
||||
width?: number | NumericalComparisonOperator
|
||||
|
||||
/**
|
||||
* Filter inventory items by whether they require shipping.
|
||||
*/
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === "true")
|
||||
requires_shipping?: boolean
|
||||
}
|
||||
|
||||
export class AdminPostInventoryItemsItemLocationLevelsReq {
|
||||
@IsString()
|
||||
location_id: string
|
||||
|
||||
@IsNumber()
|
||||
stocked_quantity: number
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
incoming_quantity?: number
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
export class AdminPostInventoryItemsItemLocationLevelsParams extends FindParams {}
|
||||
|
||||
/**
|
||||
* @schema AdminPostInventoryItemsReq
|
||||
* type: object
|
||||
* description: "The details of the inventory item to create."
|
||||
* required:
|
||||
* - variant_id
|
||||
* properties:
|
||||
* variant_id:
|
||||
* description: The ID of the variant to create the inventory item for.
|
||||
* type: string
|
||||
* sku:
|
||||
* description: The unique SKU of the associated Product Variant.
|
||||
* type: string
|
||||
* ean:
|
||||
* description: The EAN number of the item.
|
||||
* type: string
|
||||
* upc:
|
||||
* description: The UPC number of the item.
|
||||
* type: string
|
||||
* barcode:
|
||||
* description: A generic GTIN field for the Product Variant.
|
||||
* type: string
|
||||
* hs_code:
|
||||
* description: The Harmonized System code of the Inventory Item. May be used by Fulfillment Providers to pass customs information to shipping carriers.
|
||||
* type: string
|
||||
* inventory_quantity:
|
||||
* description: The amount of stock kept of the associated Product Variant.
|
||||
* type: integer
|
||||
* default: 0
|
||||
* allow_backorder:
|
||||
* description: Whether the associated Product Variant can be purchased when out of stock.
|
||||
* type: boolean
|
||||
* manage_inventory:
|
||||
* description: Whether Medusa should keep track of the inventory for the associated Product Variant.
|
||||
* type: boolean
|
||||
* default: true
|
||||
* weight:
|
||||
* description: The weight of the Inventory Item. May be used in shipping rate calculations.
|
||||
* type: number
|
||||
* length:
|
||||
* description: The length of the Inventory Item. May be used in shipping rate calculations.
|
||||
* type: number
|
||||
* height:
|
||||
* description: The height of the Inventory Item. May be used in shipping rate calculations.
|
||||
* type: number
|
||||
* width:
|
||||
* description: The width of the Inventory Item. May be used in shipping rate calculations.
|
||||
* type: number
|
||||
* origin_country:
|
||||
* description: The country in which the Inventory Item was produced. May be used by Fulfillment Providers to pass customs information to shipping carriers.
|
||||
* type: string
|
||||
* mid_code:
|
||||
* description: The Manufacturers Identification code that identifies the manufacturer of the Inventory Item. May be used by Fulfillment Providers to pass customs information to shipping carriers.
|
||||
* type: string
|
||||
* material:
|
||||
* description: The material and composition that the Inventory Item is made of, May be used by Fulfillment Providers to pass customs information to shipping carriers.
|
||||
* type: string
|
||||
* title:
|
||||
* description: The inventory item's title.
|
||||
* type: string
|
||||
* description:
|
||||
* description: The inventory item's description.
|
||||
* type: string
|
||||
* thumbnail:
|
||||
* description: The inventory item's thumbnail.
|
||||
* type: string
|
||||
* metadata:
|
||||
* description: An optional set of key-value pairs with additional information.
|
||||
* type: object
|
||||
* externalDocs:
|
||||
* description: "Learn about the metadata attribute, and how to delete and update it."
|
||||
* url: "https://docs.medusajs.com/development/entities/overview#metadata-attribute"
|
||||
*/
|
||||
export class AdminPostInventoryItemsReq {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
variant_id: string
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
sku?: string
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
hs_code?: string
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
weight?: number
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
length?: number
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
height?: number
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
width?: number
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
origin_country?: string
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
mid_code?: string
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
material?: string
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
title?: string
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
thumbnail?: string
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { adminCollectionRoutesMiddlewares } from "./admin/collections/middleware
|
||||
import { adminCurrencyRoutesMiddlewares } from "./admin/currencies/middlewares"
|
||||
import { adminCustomerGroupRoutesMiddlewares } from "./admin/customer-groups/middlewares"
|
||||
import { adminCustomerRoutesMiddlewares } from "./admin/customers/middlewares"
|
||||
import { adminInventoryRoutesMiddlewares } from "./admin/inventory-items/middlewares"
|
||||
import { adminInviteRoutesMiddlewares } from "./admin/invites/middlewares"
|
||||
import { adminPaymentRoutesMiddlewares } from "./admin/payments/middlewares"
|
||||
import { adminPriceListsRoutesMiddlewares } from "./admin/price-lists/middlewares"
|
||||
@@ -49,6 +50,7 @@ export const config: MiddlewaresConfig = {
|
||||
...adminProductRoutesMiddlewares,
|
||||
...adminPaymentRoutesMiddlewares,
|
||||
...adminPriceListsRoutesMiddlewares,
|
||||
...adminInventoryRoutesMiddlewares,
|
||||
...adminCollectionRoutesMiddlewares,
|
||||
...adminPricingRoutesMiddlewares,
|
||||
],
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
import {
|
||||
MedusaApp,
|
||||
MedusaAppMigrateUp,
|
||||
MedusaAppOutput,
|
||||
MedusaModule,
|
||||
MODULE_PACKAGE_NAMES,
|
||||
Modules,
|
||||
ModulesDefinition,
|
||||
} from "@medusajs/modules-sdk"
|
||||
import {
|
||||
CommonTypes,
|
||||
InternalModuleDeclaration,
|
||||
@@ -17,12 +8,22 @@ import {
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
FlagRouter,
|
||||
isObject,
|
||||
MedusaV2Flag,
|
||||
isObject,
|
||||
} from "@medusajs/utils"
|
||||
import {
|
||||
MODULE_PACKAGE_NAMES,
|
||||
MedusaApp,
|
||||
MedusaAppMigrateUp,
|
||||
MedusaAppOutput,
|
||||
MedusaModule,
|
||||
Modules,
|
||||
ModulesDefinition,
|
||||
} from "@medusajs/modules-sdk"
|
||||
|
||||
import { asValue } from "awilix"
|
||||
import { remoteQueryFetchData } from "../utils/remote-query-fetch-data"
|
||||
import { joinerConfig } from "../joiner-config"
|
||||
import { remoteQueryFetchData } from "../utils/remote-query-fetch-data"
|
||||
|
||||
export function mergeDefaultModules(
|
||||
modulesConfig: CommonTypes.ConfigModule["modules"]
|
||||
@@ -74,7 +75,6 @@ export async function migrateMedusaApp(
|
||||
debug: !!(configModule.projectConfig.database_logging ?? false),
|
||||
},
|
||||
}
|
||||
|
||||
const configModules = mergeDefaultModules(configModule.modules)
|
||||
|
||||
// Apply default options to legacy modules
|
||||
@@ -90,6 +90,7 @@ export async function migrateMedusaApp(
|
||||
database: {
|
||||
type: "postgres",
|
||||
url: sharedResourcesConfig.database.clientUrl,
|
||||
clientUrl: sharedResourcesConfig.database.clientUrl,
|
||||
extra: configModule.projectConfig.database_extra,
|
||||
schema: configModule.projectConfig.database_schema,
|
||||
logging: configModule.projectConfig.database_logging,
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface InventoryLevelDTO {
|
||||
stocked_quantity: number
|
||||
reserved_quantity: number
|
||||
incoming_quantity: number
|
||||
available_quantity: number
|
||||
metadata: Record<string, unknown> | null
|
||||
created_at: string | Date
|
||||
updated_at: string | Date
|
||||
|
||||
Reference in new issue
Block a user