diff --git a/packages/admin/dashboard/src/dashboard-app/routes/get-route.map.tsx b/packages/admin/dashboard/src/dashboard-app/routes/get-route.map.tsx index e2a7732707..182bf48bb4 100644 --- a/packages/admin/dashboard/src/dashboard-app/routes/get-route.map.tsx +++ b/packages/admin/dashboard/src/dashboard-app/routes/get-route.map.tsx @@ -300,6 +300,12 @@ export function getRouteMap({ { path: "", lazy: () => import("../../routes/orders/order-list"), + children: [ + { + path: "export", + lazy: () => import("../../routes/orders/order-export"), + }, + ], }, { path: ":id", diff --git a/packages/admin/dashboard/src/hooks/api/orders.tsx b/packages/admin/dashboard/src/hooks/api/orders.tsx index d7bfffe7ac..299f75d80b 100644 --- a/packages/admin/dashboard/src/hooks/api/orders.tsx +++ b/packages/admin/dashboard/src/hooks/api/orders.tsx @@ -10,8 +10,8 @@ import { import { sdk } from "../../lib/client" import { queryClient } from "../../lib/query-client" import { queryKeysFactory, TQueryKey } from "../../lib/query-key-factory" -import { reservationItemsQueryKeys } from "./reservations" import { inventoryItemsQueryKeys } from "./inventory" +import { reservationItemsQueryKeys } from "./reservations" const ORDERS_QUERY_KEY = "orders" as const const _orderKeys = queryKeysFactory(ORDERS_QUERY_KEY) as TQueryKey<"orders"> & { @@ -438,3 +438,20 @@ export const useUpdateOrderChange = ( ...options, }) } + +export const useExportOrders = ( + query?: HttpTypes.AdminOrderFilters, + options?: UseMutationOptions< + { transaction_id: string }, + FetchError, + HttpTypes.AdminOrderFilters + > +) => { + return useMutation({ + mutationFn: () => sdk.admin.order.export(query), + onSuccess: (data, variables, context) => { + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/packages/admin/dashboard/src/i18n/translations/$schema.json b/packages/admin/dashboard/src/i18n/translations/$schema.json index d1da922930..cb547d3c13 100644 --- a/packages/admin/dashboard/src/i18n/translations/$schema.json +++ b/packages/admin/dashboard/src/i18n/translations/$schema.json @@ -4049,6 +4049,45 @@ "required": ["noRecordsMessage"], "additionalProperties": false }, + "export": { + "type": "object", + "properties": { + "header": { + "type": "string" + }, + "description": { + "type": "string" + }, + "success": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["title", "description"], + "additionalProperties": false + }, + "filters": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["title", "description"], + "additionalProperties": false + } + }, + "required": ["header", "description", "success", "filters"], + "additionalProperties": false + }, "status": { "type": "object", "properties": { @@ -5740,6 +5779,7 @@ "orderCanceled", "onDateFromSalesChannel", "list", + "export", "status", "summary", "transfer", diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index 6c7ee59d6c..08a9b566c9 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -1080,6 +1080,18 @@ "list": { "noRecordsMessage": "Your orders will show up here." }, + "export": { + "header": "Export Order List", + "description": "Export the order list to a CSV file.", + "success": { + "title": "Export started", + "description": "You will be notified when the export is ready." + }, + "filters": { + "title": "Filters", + "description": "The following filters will be applied to the export." + } + }, "status": { "not_paid": "Not paid", "pending": "Pending", diff --git a/packages/admin/dashboard/src/routes/orders/order-export/components/export-filters.tsx b/packages/admin/dashboard/src/routes/orders/order-export/components/export-filters.tsx new file mode 100644 index 0000000000..594e28d939 --- /dev/null +++ b/packages/admin/dashboard/src/routes/orders/order-export/components/export-filters.tsx @@ -0,0 +1,22 @@ +import { Heading, Text } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { DataTableFilter } from "../../../../components/table/data-table/data-table-filter" +import { useOrderTableFilters } from "../../order-list/components/order-list-table/use-order-table-filters" + +export const ExportFilters = () => { + const { t } = useTranslation() + const filters = useOrderTableFilters() + + return ( +
+ {t("orders.export.filters.title")} + + {t("orders.export.filters.description")} + + +
+ +
+
+ ) +} diff --git a/packages/admin/dashboard/src/routes/orders/order-export/index.ts b/packages/admin/dashboard/src/routes/orders/order-export/index.ts new file mode 100644 index 0000000000..7e1b227fb2 --- /dev/null +++ b/packages/admin/dashboard/src/routes/orders/order-export/index.ts @@ -0,0 +1 @@ +export { OrderExport as Component } from "./order-export" diff --git a/packages/admin/dashboard/src/routes/orders/order-export/order-export.tsx b/packages/admin/dashboard/src/routes/orders/order-export/order-export.tsx new file mode 100644 index 0000000000..a7ffbbeec2 --- /dev/null +++ b/packages/admin/dashboard/src/routes/orders/order-export/order-export.tsx @@ -0,0 +1,66 @@ +import { Button, Heading, toast } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { RouteDrawer, useRouteModal } from "../../../components/modals" +import { useExportOrders } from "../../../hooks/api" +import { useOrderTableQuery } from "../../../hooks/table/query" +import { ExportFilters } from "./components/export-filters" + +export const OrderExport = () => { + const { t } = useTranslation() + + return ( + + + + {t("orders.export.header")} + + + {t("orders.export.description")} + + + + + ) +} + +const OrderExportContent = () => { + const { t } = useTranslation() + const { searchParams } = useOrderTableQuery({}) + + const { mutateAsync } = useExportOrders(searchParams) + const { handleSuccess } = useRouteModal() + + const handleExportRequest = async () => { + await mutateAsync(searchParams, { + onSuccess: () => { + toast.info(t("orders.export.success.title"), { + description: t("orders.export.success.description"), + }) + handleSuccess() + }, + onError: (err) => { + toast.error(err.message) + }, + }) + } + + return ( + <> + + + + +
+ + + + +
+
+ + ) +} diff --git a/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/configurable-order-list-table.tsx b/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/configurable-order-list-table.tsx index b5b5a5c8a8..10b4d05094 100644 --- a/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/configurable-order-list-table.tsx +++ b/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/configurable-order-list-table.tsx @@ -1,16 +1,24 @@ import { useTranslation } from "react-i18next" +import { Outlet, useLocation } from "react-router-dom" + import { ConfigurableDataTable } from "../../../../../components/table/configurable-data-table" import { useOrderTableAdapter } from "./order-table-adapter" export const ConfigurableOrderListTable = () => { const { t } = useTranslation() - const orderAdapter = useOrderTableAdapter() + const location = useLocation() + const adapter = useOrderTableAdapter() return ( - + <> + + + ) } diff --git a/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/order-list-table.tsx b/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/order-list-table.tsx index cb10250a67..1dfdbe79b4 100644 --- a/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/order-list-table.tsx +++ b/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/order-list-table.tsx @@ -1,15 +1,16 @@ -import { Container, Heading } from "@medusajs/ui" +import { Button, Container, Heading } from "@medusajs/ui" import { keepPreviousData } from "@tanstack/react-query" import { useTranslation } from "react-i18next" +import { Link, Outlet, useLocation } from "react-router-dom" import { _DataTable } from "../../../../../components/table/data-table/data-table" import { useOrders } from "../../../../../hooks/api/orders" import { useOrderTableColumns } from "../../../../../hooks/table/columns/use-order-table-columns" -import { useOrderTableFilters } from "./use-order-table-filters" import { useOrderTableQuery } from "../../../../../hooks/table/query/use-order-table-query" import { useDataTable } from "../../../../../hooks/use-data-table" import { useFeatureFlag } from "../../../../../providers/feature-flag-provider" import { ConfigurableOrderListTable } from "./configurable-order-list-table" +import { useOrderTableFilters } from "./use-order-table-filters" import { DEFAULT_FIELDS } from "../../const" @@ -17,6 +18,7 @@ const PAGE_SIZE = 20 export const OrderListTable = () => { const { t } = useTranslation() + const location = useLocation() const isViewConfigEnabled = useFeatureFlag("view_configurations") // If feature flag is enabled, use the new configurable table @@ -57,6 +59,9 @@ export const OrderListTable = () => {
{t("orders.domain")} +
<_DataTable columns={columns} @@ -78,6 +83,7 @@ export const OrderListTable = () => { message: t("orders.list.noRecordsMessage"), }} /> +
) } diff --git a/packages/core/core-flows/src/order/steps/export-orders.ts b/packages/core/core-flows/src/order/steps/export-orders.ts new file mode 100644 index 0000000000..b0ea335bec --- /dev/null +++ b/packages/core/core-flows/src/order/steps/export-orders.ts @@ -0,0 +1,178 @@ +import { + FilterableOrderProps, + IFileModuleService, + OrderDTO, +} from "@medusajs/framework/types" +import { + ContainerRegistrationKeys, + Modules, + deduplicate, +} from "@medusajs/framework/utils" +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" +import { json2csv } from "json-2-csv" + +import { + getLastFulfillmentStatus, + getLastPaymentStatus, +} from "../utils/aggregate-status" + +export type ExportOrdersStepInput = { + batch_size?: number | string + select: string[] + filter?: FilterableOrderProps +} + +export type ExportOrdersStepOutput = { + id: string + filename: string +} + +export const exportOrdersStepId = "export-orders" + +const normalizeOrderForExport = (order: OrderDTO): object => { + const order_ = order as any + const customer = order_.customer || {} + const shippingAddress = order_.shipping_address || {} + + return JSON.parse( + JSON.stringify({ + Order_ID: order.id, + Display_ID: order.display_id, + "Order status": order.status, + Date: order.created_at, + "Customer First name": customer.first_name || "", + "Customer Last name": customer.last_name || "", + "Customer Email": customer.email || "", + "Customer ID": customer.id || "", + "Shipping Address 1": shippingAddress.address_1 || "", + "Shipping Address 2": shippingAddress.address_2 || "", + "Shipping Country Code": shippingAddress.country_code || "", + "Shipping City": shippingAddress.city || "", + "Shipping Postal Code": shippingAddress.postal_code || "", + "Shipping Region ID": order.region_id, + "Fulfillment Status": order_.fulfillment_status, + "Payment Status": order_.payment_status, + Subtotal: order.subtotal, + "Shipping Total": order.shipping_total, + "Discount Total": order.discount_total, + "Gift Card Total": order.gift_card_total, + "Refunded Total": order_.refunded_total, + "Tax Total": order.tax_total, + Total: order.total, + "Currency Code": order.currency_code, + }) + ) +} + +export const exportOrdersStep = createStep( + exportOrdersStepId, + async (input: ExportOrdersStepInput, { container }) => { + const query = container.resolve(ContainerRegistrationKeys.QUERY) + const fileModule = container.resolve(Modules.FILE) + + const filename = `${Date.now()}-order-exports.csv` + const { writeStream, promise, fileKey } = await fileModule.getUploadStream({ + filename, + mimeType: "text/csv", + }) + + const pageSize = !isNaN(parseInt(input?.batch_size as string)) + ? parseInt(input?.batch_size as string, 10) + : 50 + + let page = 0 + let hasHeader = false + + const fields = deduplicate([ + ...input.select, + "id", + "status", + "items.*", + "customer.*", + "shipping_address.*", + "payment_collections.status", + "payment_collections.amount", + "payment_collections.captured_amount", + "payment_collections.refunded_amount", + "fulfillments.packed_at", + "fulfillments.shipped_at", + "fulfillments.delivered_at", + "fulfillments.canceled_at", + ]) + + while (true) { + const { data: orders } = await query.graph({ + entity: "order", + filters: { + ...input.filter, + status: { + $ne: "draft", + }, + }, + pagination: { + skip: page * pageSize, + take: pageSize, + }, + fields, + }) + + if (orders.length === 0) { + break + } + + for (let i = 0; i < orders.length; i++) { + const order = orders[i] + const order_ = order as any + + order_.payment_status = getLastPaymentStatus(order_) + order_.fulfillment_status = getLastFulfillmentStatus(order_) + + delete order_.version + delete order.payment_collections + delete order.fulfillments + + orders[i] = normalizeOrderForExport(order) + } + + const batchCsv = json2csv(orders, { + prependHeader: !hasHeader, + arrayIndexesAsKeys: true, + expandNestedObjects: true, + expandArrayObjects: true, + unwindArrays: false, + preventCsvInjection: true, + emptyFieldValue: "", + }) + + const ok = writeStream.write((hasHeader ? "\n" : "") + batchCsv) + if (!ok) { + await new Promise((resolve) => writeStream.once("drain", resolve)) + } + + hasHeader = true + + if (orders.length < pageSize) { + break + } + + page += 1 + } + + writeStream.end() + + await promise + + return new StepResponse( + { id: fileKey, filename } as ExportOrdersStepOutput, + fileKey + ) + }, + async (fileId, { container }) => { + if (!fileId) { + return + } + + const fileModule: IFileModuleService = container.resolve(Modules.FILE) + await fileModule.deleteFiles(fileId) + } +) diff --git a/packages/core/core-flows/src/order/steps/index.ts b/packages/core/core-flows/src/order/steps/index.ts index a0422edaa6..3cea34b177 100644 --- a/packages/core/core-flows/src/order/steps/index.ts +++ b/packages/core/core-flows/src/order/steps/index.ts @@ -17,10 +17,11 @@ export * from "./delete-order-change-actions" export * from "./delete-order-changes" export * from "./delete-order-shipping-methods" export * from "./exchange/cancel-exchange" -export * from "./list-order-change-actions-by-type" export * from "./exchange/create-exchange" export * from "./exchange/create-exchange-items-from-actions" export * from "./exchange/delete-exchanges" +export * from "./export-orders" +export * from "./list-order-change-actions-by-type" export * from "./preview-order-change" export * from "./register-delivery" export * from "./register-fulfillment" diff --git a/packages/core/core-flows/src/order/workflows/export-orders.ts b/packages/core/core-flows/src/order/workflows/export-orders.ts new file mode 100644 index 0000000000..308c58d4a2 --- /dev/null +++ b/packages/core/core-flows/src/order/workflows/export-orders.ts @@ -0,0 +1,142 @@ +import { FilterableOrderProps } from "@medusajs/framework/types" +import { + WorkflowData, + createWorkflow, + transform, +} from "@medusajs/framework/workflows-sdk" +import { useRemoteQueryStep } from "../../common" +import { notifyOnFailureStep, sendNotificationsStep } from "../../notification" +import { exportOrdersStep } from "../steps" + +/** + * The data to export orders. + */ +export type ExportOrdersDTO = { + /** + * The fields to select. These fields will be passed to + * [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query), so you can + * pass order properties or any relation names, including custom links. + */ + select: string[] + /** + * The filters to select which orders to export. + */ + filter?: FilterableOrderProps +} + +export const exportOrdersWorkflowId = "export-orders" +/** + * This workflow exports orders matching the specified filters. It's used to + * export orders to a CSV file. + * + * :::note + * + * This workflow doesn't return the exported orders. Instead, it sends a notification to the admin + * users that they can download the exported orders. + * + * ::: + * + * @example + * To export all orders: + * + * ```ts + * const { result } = await exportOrdersWorkflow(container) + * .run({ + * input: { + * select: ["*"], + * } + * }) + * ``` + * + * To export orders matching a criteria: + * + * ```ts + * const { result } = await exportOrdersWorkflow(container) + * .run({ + * input: { + * select: ["*"], + * filter: { + * created_at: { + * $gte: "2024-01-01", + * $lte: "2024-12-31" + * } + * } + * } + * }) + * ``` + * + * To export orders within a date range: + * + * ```ts + * const { result } = await exportOrdersWorkflow(container) + * .run({ + * input: { + * select: ["*"], + * filter: { + * created_at: { + * $gte: "2024-01-01T00:00:00Z", + * $lte: "2024-01-31T23:59:59Z" + * } + * } + * } + * }) + * ``` + * + * @summary + * + * Export orders with filtering capabilities. + */ +export const exportOrdersWorkflow = createWorkflow( + exportOrdersWorkflowId, + (input: WorkflowData): WorkflowData => { + const file = exportOrdersStep(input).config({ + async: true, + backgroundExecution: true, + }) + + const failureNotification = transform({ input }, (data) => { + return [ + { + // We don't need the recipient here for now, but if we want to push feed notifications to a specific user we could add it. + to: "", + channel: "feed", + template: "admin-ui", + data: { + title: "Order export", + description: `Failed to export orders, please try again later.`, + }, + }, + ] + }) + notifyOnFailureStep(failureNotification) + + const fileDetails = useRemoteQueryStep({ + fields: ["id", "url"], + entry_point: "file", + variables: { id: file.id }, + list: false, + }) + + const notifications = transform({ fileDetails, file }, (data) => { + return [ + { + // We don't need the recipient here for now, but if we want to push feed notifications to a specific user we could add it. + to: "", + channel: "feed", + template: "admin-ui", + data: { + title: "Order export", + description: "Order export completed successfully!", + file: { + filename: data.file.filename, + url: data.fileDetails.url, + mimeType: "text/csv", + }, + }, + }, + ] + }) + + sendNotificationsStep(notifications) + } +) diff --git a/packages/core/core-flows/src/order/workflows/index.ts b/packages/core/core-flows/src/order/workflows/index.ts index a972ad1905..6ba0edc16e 100644 --- a/packages/core/core-flows/src/order/workflows/index.ts +++ b/packages/core/core-flows/src/order/workflows/index.ts @@ -18,6 +18,7 @@ export * from "./claim/update-claim-add-item" export * from "./claim/update-claim-item" export * from "./claim/update-claim-shipping-method" export * from "./complete-orders" +export * from "./compute-adjustments-for-preview" export * from "./create-fulfillment" export * from "./create-or-update-order-payment-collection" export * from "./create-order" @@ -41,6 +42,7 @@ export * from "./exchange/remove-exchange-item-action" export * from "./exchange/remove-exchange-shipping-method" export * from "./exchange/update-exchange-add-item" export * from "./exchange/update-exchange-shipping-method" +export * from "./export-orders" export * from "./fetch-shipping-option" export * from "./get-order-detail" export * from "./get-orders-list" @@ -48,9 +50,9 @@ export * from "./list-shipping-options-for-order" export * from "./mark-order-fulfillment-as-delivered" export * from "./mark-payment-collection-as-paid" export * from "./maybe-refresh-shipping-methods" +export * from "./on-carry-promotions-flag-set" export * from "./order-edit/begin-order-edit" export * from "./order-edit/cancel-begin-order-edit" -export * from "./compute-adjustments-for-preview" export * from "./order-edit/confirm-order-edit-request" export * from "./order-edit/create-order-edit-shipping-method" export * from "./order-edit/order-edit-add-new-item" @@ -81,7 +83,6 @@ export * from "./return/update-receive-item-return-request" export * from "./return/update-request-item-return" export * from "./return/update-return" export * from "./return/update-return-shipping-method" -export * from "./on-carry-promotions-flag-set" export * from "./transfer/accept-order-transfer" export * from "./transfer/cancel-order-transfer" export * from "./transfer/decline-order-transfer" diff --git a/packages/core/js-sdk/src/admin/order.ts b/packages/core/js-sdk/src/admin/order.ts index 17bde8fa4e..233efd6051 100644 --- a/packages/core/js-sdk/src/admin/order.ts +++ b/packages/core/js-sdk/src/admin/order.ts @@ -638,7 +638,7 @@ export class Order { * This method updates an order change. It sends a request to the * [Update Order Change](https://docs.medusajs.com/api/admin#order-changes_postorder-changesid) * API route. - * + * * @since 2.12.0 * * @param id - The order change's ID. @@ -674,4 +674,36 @@ export class Order { } ) } + + /** + * This method starts an order export process to retrieve a CSV of exported orders. + * + * You'll receive in the response the transaction ID of the workflow generating the CSV file. + * To check the status of the execution, send a `GET` request to + * `/admin/workflows-executions/export-orders/:transaction-id`. + * + * Once the execution finishes successfully, a notification is created for the export. + * You can retrieve the notifications using the `/admin/notification` API route to + * retrieve the file's download URL. + * + * @param query - Filters to specify which orders to export. + * @param headers - Headers to pass in the request. + * @returns The export's details. + * + * @example + * sdk.admin.order.export({}) + * .then(({ transaction_id }) => { + * console.log(transaction_id) + * }) + */ + async export(query?: HttpTypes.AdminOrderFilters, headers?: ClientHeaders) { + return await this.client.fetch( + `/admin/orders/export`, + { + method: "POST", + headers, + query, + } + ) + } } diff --git a/packages/core/types/src/file/provider.ts b/packages/core/types/src/file/provider.ts index 66b263149e..c40118d603 100644 --- a/packages/core/types/src/file/provider.ts +++ b/packages/core/types/src/file/provider.ts @@ -1,4 +1,4 @@ -import { Readable } from "stream" +import { Readable, Writable } from "stream" import { FileAccessPermission } from "./common" /** @@ -109,6 +109,28 @@ export type ProviderGetPresignedUploadUrlDTO = { expiresIn?: number } +/** + * @interface + * + * The details of the file to upload via a stream. + */ +export type ProviderUploadStreamDTO = { + /** + * The filename of the uploaded file + */ + filename: string + + /** + * The mimetype of the uploaded file + */ + mimeType: string + + /** + * The access level of the file. Defaults to private if not passed + */ + access?: FileAccessPermission +} + export interface IFileProvider { /** * This method is used to upload a file @@ -178,4 +200,14 @@ export interface IFileProvider { * Get the file contents as a Node.js Buffer */ getAsBuffer(fileData: ProviderGetFileDTO): Promise + + /** + * Get a writeable stream to upload a file. + */ + getUploadStream(fileData: ProviderUploadStreamDTO): Promise<{ + writeStream: Writable + promise: Promise + url: string + fileKey: string + }> } diff --git a/packages/core/types/src/file/service.ts b/packages/core/types/src/file/service.ts index a3affff543..f1ddf9bf1d 100644 --- a/packages/core/types/src/file/service.ts +++ b/packages/core/types/src/file/service.ts @@ -1,10 +1,15 @@ +import type { Writable } from "stream" import { Readable } from "stream" -import { IModuleService } from "../modules-sdk" -import { FileDTO, FilterableFileProps, UploadFileUrlDTO } from "./common" import { FindConfig } from "../common" +import { IModuleService } from "../modules-sdk" import { Context } from "../shared-context" -import { IFileProvider } from "./provider" +import { FileDTO, FilterableFileProps, UploadFileUrlDTO } from "./common" import { CreateFileDTO, GetUploadFileUrlDTO } from "./mutations" +import { + IFileProvider, + ProviderFileResultDTO, + ProviderUploadStreamDTO, +} from "./provider" export interface IFileModuleService extends IModuleService { /** @@ -203,4 +208,14 @@ export interface IFileModuleService extends IModuleService { * contents.toString('utf-8') */ getAsBuffer(id: string, sharedContext?: Context): Promise + + /** + * Get a writeable stream to upload a file. + */ + getUploadStream(fileData: ProviderUploadStreamDTO): Promise<{ + writeStream: Writable + promise: Promise + url: string + fileKey: string + }> } diff --git a/packages/core/types/src/http/order/admin/responses.ts b/packages/core/types/src/http/order/admin/responses.ts index 548e2631a1..aba222e4fb 100644 --- a/packages/core/types/src/http/order/admin/responses.ts +++ b/packages/core/types/src/http/order/admin/responses.ts @@ -47,3 +47,10 @@ export interface AdminOrderPreviewResponse { */ order: AdminOrderPreview } + +export interface AdminExportOrderResponse { + /** + * The ID of the export order workflow's transaction. + */ + transaction_id: string +} diff --git a/packages/core/utils/src/file/abstract-file-provider.ts b/packages/core/utils/src/file/abstract-file-provider.ts index fe070684cf..be4dfc46c7 100644 --- a/packages/core/utils/src/file/abstract-file-provider.ts +++ b/packages/core/utils/src/file/abstract-file-provider.ts @@ -1,5 +1,5 @@ -import type { Readable } from "stream" import { FileTypes, IFileProvider } from "@medusajs/types" +import type { Readable, Writable } from "stream" /** * ### constructor @@ -234,4 +234,34 @@ export class AbstractFileProviderService implements IFileProvider { getAsBuffer(fileData: FileTypes.ProviderGetFileDTO): Promise { throw Error("getAsBuffer must be overridden by the child class") } + + /** + * This method returns a writeable stream to upload a file. + * + * @param {FileTypes.ProviderUploadStreamDTO} fileData - The details of the file to upload. + * @returns {Promise<{ writeStream: Writable, promise: Promise, url: string, fileKey: string }>} The writeable stream and upload promise. + * + * @since 2.8.0 + * + * @example + * class MyFileProviderService extends AbstractFileProviderService { + * // ... + * async getUploadStream(fileData: FileTypes.ProviderUploadStreamDTO): Promise<{ + * writeStream: Writable + * promise: Promise + * url: string + * fileKey: string + * }> { + * // TODO logic to get the writeable stream + * } + * } + */ + getUploadStream(fileData: FileTypes.ProviderUploadStreamDTO): Promise<{ + writeStream: Writable + promise: Promise + url: string + fileKey: string + }> { + throw Error("getUploadStream must be overridden by the child class") + } } diff --git a/packages/medusa/src/api/admin/orders/export/route.ts b/packages/medusa/src/api/admin/orders/export/route.ts new file mode 100644 index 0000000000..12b0042af6 --- /dev/null +++ b/packages/medusa/src/api/admin/orders/export/route.ts @@ -0,0 +1,20 @@ +import { exportOrdersWorkflow } from "@medusajs/core-flows" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { HttpTypes } from "@medusajs/framework/types" + +export const POST = async ( + req: AuthenticatedMedusaRequest<{}, HttpTypes.AdminOrderFilters>, + res: MedusaResponse +) => { + const selectFields = req.queryConfig.fields ?? [] + const input = { select: selectFields, filter: req.filterableFields } + + const { transaction } = await exportOrdersWorkflow(req.scope).run({ + input, + }) + + res.status(202).json({ transaction_id: transaction.transactionId }) +} diff --git a/packages/medusa/src/api/admin/orders/middlewares.ts b/packages/medusa/src/api/admin/orders/middlewares.ts index 25266e0644..3e9b68d2b0 100644 --- a/packages/medusa/src/api/admin/orders/middlewares.ts +++ b/packages/medusa/src/api/admin/orders/middlewares.ts @@ -32,6 +32,16 @@ export const adminOrderRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/orders/export", + middlewares: [ + validateAndTransformQuery( + AdminGetOrdersParams, + QueryConfig.exportTransformQueryConfig + ), + ], + }, { method: ["GET"], matcher: "/admin/orders/:id", diff --git a/packages/medusa/src/api/admin/orders/query-config.ts b/packages/medusa/src/api/admin/orders/query-config.ts index db7b886785..c91ee3f4c1 100644 --- a/packages/medusa/src/api/admin/orders/query-config.ts +++ b/packages/medusa/src/api/admin/orders/query-config.ts @@ -118,3 +118,33 @@ export const listShippingOptionsQueryConfig = { defaultLimit: 100, isList: true, } + +export const defaultAdminExportOrderFields = [ + "id", + "display_id", + "status", + "created_at", + "updated_at", + "email", + "currency_code", + "region_id", + "subtotal", + "tax_total", + "shipping_total", + "discount_total", + "gift_card_total", + "total", + "*customer", + "*shipping_address", + "*billing_address", + "*sales_channel", + "*items", + "*shipping_methods", + "*payment_collections", + "*fulfillments", +] + +export const exportTransformQueryConfig = { + defaults: defaultAdminExportOrderFields, + isList: true, +} diff --git a/packages/modules/file/src/services/file-module-service.ts b/packages/modules/file/src/services/file-module-service.ts index d162ca6593..0dc5acb9de 100644 --- a/packages/modules/file/src/services/file-module-service.ts +++ b/packages/modules/file/src/services/file-module-service.ts @@ -1,19 +1,19 @@ -import type { Readable } from "stream" import { Context, CreateFileDTO, - GetUploadFileUrlDTO, FileDTO, - UploadFileUrlDTO, FileTypes, FilterableFileProps, FindConfig, + GetUploadFileUrlDTO, ModuleJoinerConfig, + UploadFileUrlDTO, } from "@medusajs/framework/types" +import type { Readable, Writable } from "stream" +import { MedusaError } from "@medusajs/framework/utils" import { joinerConfig } from "../joiner-config" import FileProviderService from "./file-provider-service" -import { MedusaError } from "@medusajs/framework/utils" type InjectedDependencies = { fileProviderService: FileProviderService @@ -172,4 +172,25 @@ export default class FileModuleService implements FileTypes.IFileModuleService { getAsBuffer(id: string): Promise { return this.fileProviderService_.getAsBuffer({ fileKey: id }) } + + /** + * Get a writeable stream to upload a file. + * + * @example + * const { writeStream, promise } = await fileModuleService.getUploadStream({ + * filename: "test.csv", + * mimeType: "text/csv", + * }) + * + * stream.pipe(writeStream) + * const result = await promise + */ + getUploadStream(data: FileTypes.ProviderUploadStreamDTO): Promise<{ + writeStream: Writable + promise: Promise + url: string + fileKey: string + }> { + return this.fileProviderService_.getUploadStream(data) + } } diff --git a/packages/modules/file/src/services/file-provider-service.ts b/packages/modules/file/src/services/file-provider-service.ts index 959ab9c254..7568dceec0 100644 --- a/packages/modules/file/src/services/file-provider-service.ts +++ b/packages/modules/file/src/services/file-provider-service.ts @@ -1,7 +1,7 @@ -import type { Readable } from "stream" import { Constructor, FileTypes } from "@medusajs/framework/types" import { MedusaError } from "@medusajs/framework/utils" import { FileProviderRegistrationPrefix } from "@types" +import type { Readable, Writable } from "stream" type InjectedDependencies = { [ @@ -81,4 +81,13 @@ export default class FileProviderService { getAsBuffer(fileData: FileTypes.ProviderGetFileDTO): Promise { return this.fileProvider_.getAsBuffer(fileData) } + + getUploadStream(fileData: FileTypes.ProviderUploadStreamDTO): Promise<{ + writeStream: Writable + promise: Promise + url: string + fileKey: string + }> { + return this.fileProvider_.getUploadStream(fileData) + } } diff --git a/packages/modules/providers/file-local/integration-tests/__fixtures__/catphoto.jpg b/packages/modules/providers/file-local/integration-tests/__fixtures__/catphoto.jpg new file mode 100644 index 0000000000..6d44d5e1dd Binary files /dev/null and b/packages/modules/providers/file-local/integration-tests/__fixtures__/catphoto.jpg differ diff --git a/packages/modules/providers/file-local/integration-tests/__tests__/services.spec.ts b/packages/modules/providers/file-local/integration-tests/__tests__/services.spec.ts new file mode 100644 index 0000000000..6b93255d83 --- /dev/null +++ b/packages/modules/providers/file-local/integration-tests/__tests__/services.spec.ts @@ -0,0 +1,119 @@ +import { FileSystem } from "@medusajs/utils" +import fs from "fs/promises" +import path from "path" +import { LocalFileService } from "../../src/services/local-file" + +jest.setTimeout(10000) + +describe("Local File Plugin", () => { + let localService: LocalFileService + + const fixtureImagePath = + process.cwd() + "/integration-tests/__fixtures__/catphoto.jpg" + + const uploadDir = path.join( + process.cwd(), + "integration-tests/__tests__/uploads" + ) + + const fileSystem = new FileSystem(uploadDir) + + beforeAll(async () => { + localService = new LocalFileService( + { + logger: console as any, + }, + { + upload_dir: uploadDir, + backend_url: "http://localhost:9000/static", + } + ) + }) + + afterAll(async () => { + await fileSystem.cleanup() + }) + + it(`should upload, read, and then delete a public file successfully`, async () => { + const fileContent = await fs.readFile(fixtureImagePath) + const fixtureAsBase64 = fileContent.toString("base64") + + const resp = await localService.upload({ + filename: "catphoto.jpg", + mimeType: "image/jpeg", + content: fileContent as any, + access: "public", + }) + + expect(resp).toEqual({ + key: expect.stringMatching(/catphoto.*\.jpg/), + url: expect.stringMatching( + /http:\/\/localhost:9000\/static\/.*catphoto.*\.jpg/ + ), + }) + + // For local file provider, we can verify the file exists on disk + const fileKey = resp.key + const baseDir = uploadDir + const filePath = path.join(baseDir, fileKey) + + const fileOnDisk = await fs.readFile(filePath) + + const fileOnDiskAsBase64 = fileOnDisk.toString("base64") + + expect(fileOnDiskAsBase64).toEqual(fixtureAsBase64) + + const signedUrl = await localService.getPresignedDownloadUrl({ + fileKey: resp.key, + }) + + expect(signedUrl).toEqual(resp.url) + + const buffer = await localService.getAsBuffer({ fileKey: resp.key }) + expect(buffer).toEqual(fileContent) + + await localService.delete({ fileKey: resp.key }) + + await expect(fs.access(filePath)).rejects.toThrow() + }) + + it("uploads using stream", async () => { + const fileContent = await fs.readFile(fixtureImagePath) + + const { writeStream, promise } = await localService.getUploadStream({ + filename: "catphoto-stream.jpg", + mimeType: "image/jpeg", + access: "public", + }) + + writeStream.write(fileContent) + writeStream.end() + + const resp = await promise + + expect(resp).toEqual({ + key: expect.stringMatching(/catphoto-stream.*\.jpg/), + url: expect.stringMatching( + /http:\/\/localhost:9000\/static\/.*catphoto-stream.*\.jpg/ + ), + }) + + const fileKey = resp.key + const filePath = path.join(uploadDir, fileKey) + + const fileOnDisk = await fs.readFile(filePath) + expect(fileOnDisk).toEqual(fileContent) + + const signedUrl = await localService.getPresignedDownloadUrl({ + fileKey: resp.key, + }) + + expect(signedUrl).toEqual(resp.url) + + const buffer = await localService.getAsBuffer({ fileKey: resp.key }) + expect(buffer).toEqual(fileContent) + + await localService.delete({ fileKey: resp.key }) + await expect(fs.access(filePath)).rejects.toThrow() + }) +}) diff --git a/packages/modules/providers/file-local/package.json b/packages/modules/providers/file-local/package.json index 8b63377efc..93f5a18c25 100644 --- a/packages/modules/providers/file-local/package.json +++ b/packages/modules/providers/file-local/package.json @@ -21,6 +21,7 @@ "license": "MIT", "scripts": { "test": "../../../../node_modules/.bin/jest --passWithNoTests src", + "test:integration": "../../../../node_modules/.bin/jest --passWithNoTests --forceExit --testPathPattern=\"integration-tests/__tests__/[^/]*\\.spec\\.ts\"", "build": "yarn run -T rimraf dist && yarn run -T tsc --build ./tsconfig.json", "watch": "yarn run -T tsc --watch" }, diff --git a/packages/modules/providers/file-local/src/services/local-file.ts b/packages/modules/providers/file-local/src/services/local-file.ts index 5ca891fbbe..534d5e5634 100644 --- a/packages/modules/providers/file-local/src/services/local-file.ts +++ b/packages/modules/providers/file-local/src/services/local-file.ts @@ -3,10 +3,10 @@ import { AbstractFileProviderService, MedusaError, } from "@medusajs/framework/utils" -import { createReadStream } from "fs" +import { createReadStream, createWriteStream } from "fs" import fs from "fs/promises" import path from "path" -import type { Readable } from "stream" +import type { Readable, Writable } from "stream" export class LocalFileService extends AbstractFileProviderService { static identifier = "localfs" @@ -78,6 +78,59 @@ export class LocalFileService extends AbstractFileProviderService { } } + async getUploadStream(fileData: FileTypes.ProviderUploadStreamDTO): Promise<{ + writeStream: Writable + promise: Promise + url: string + fileKey: string + }> { + if (!fileData.filename) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `No filename provided` + ) + } + + const parsedFilename = path.parse(fileData.filename) + const baseDir = + fileData.access === "public" ? this.uploadDir_ : this.privateUploadDir_ + await this.ensureDirExists(baseDir, parsedFilename.dir) + + const fileKey = path.join( + parsedFilename.dir, + // We prepend "private" to the file key so deletions and presigned URLs can know which folder to look into + `${fileData.access === "public" ? "" : "private-"}${Date.now()}-${ + parsedFilename.base + }` + ) + + const filePath = this.getUploadFilePath(baseDir, fileKey) + const fileUrl = this.getUploadFileUrl(fileKey) + + const writeStream = createWriteStream(filePath) + + const promise = new Promise( + (resolve, reject) => { + writeStream.on("finish", () => { + resolve({ + url: fileUrl, + key: fileKey, + }) + }) + writeStream.on("error", (err) => { + reject(err) + }) + } + ) + + return { + writeStream, + promise, + url: fileUrl, + fileKey, + } + } + async delete( files: FileTypes.ProviderDeleteFileDTO | FileTypes.ProviderDeleteFileDTO[] ): Promise { diff --git a/packages/modules/providers/file-s3/integration-tests/__tests__/services.spec.ts b/packages/modules/providers/file-s3/integration-tests/__tests__/services.spec.ts index d205900601..ed914469f7 100644 --- a/packages/modules/providers/file-s3/integration-tests/__tests__/services.spec.ts +++ b/packages/modules/providers/file-s3/integration-tests/__tests__/services.spec.ts @@ -49,7 +49,7 @@ describe.skip("S3 File Plugin", () => { expect(resp).toEqual({ key: expect.stringMatching(/tests\/catphoto.*\.jpg/), - url: expect.stringMatching(/https:\/\/.*\.jpg/), + url: expect.stringMatching(/https?:\/\/.*\.jpg/), }) const urlResp = await axios.get(resp.url).catch((e) => e.response) @@ -95,7 +95,7 @@ describe.skip("S3 File Plugin", () => { expect(resp).toEqual({ key: expect.stringMatching(/tests\/catphoto-か.*\.jpg/), - url: expect.stringMatching(/https:\/\/.*\/catphoto-%E3%81%8B.*\.jpg/), + url: expect.stringMatching(/https?:\/\/.*\/catphoto-%E3%81%8B.*\.jpg/), }) }) @@ -112,7 +112,7 @@ describe.skip("S3 File Plugin", () => { expect(resp).toEqual({ key: expect.stringMatching(/tests\/catphoto.*\.jpg/), - url: expect.stringMatching(/https:\/\/.*\/cat%3Fphoto.*\.jpg/), + url: expect.stringMatching(/https?:\/\/.*\/cat%3Fphoto.*\.jpg/), }) }) @@ -128,7 +128,7 @@ describe.skip("S3 File Plugin", () => { expect(resp).toEqual({ key: expect.stringMatching(/tests\/catphoto.*\.jpg/), - url: expect.stringMatching(/https:\/\/.*catphoto\.jpg/), + url: expect.stringMatching(/https?:\/\/.*catphoto\.jpg/), }) const uploadResp = await axios.put(resp.url, fileContent, { @@ -169,7 +169,7 @@ describe.skip("S3 File Plugin", () => { expect(resp).toEqual({ key: expect.stringMatching(/tests\/testfolder\/catphoto.*\.jpg/), - url: expect.stringMatching(/https:\/\/.*testfolder\/catphoto\.jpg/), + url: expect.stringMatching(/https?:\/\/.*testfolder\/catphoto\.jpg/), }) const uploadResp = await axios.put(resp.url, fileContent, { @@ -221,4 +221,42 @@ describe.skip("S3 File Plugin", () => { { fileKey: cat2.key }, ]) }) + + it("uploads using stream", async () => { + const fileContent = await fs.readFile(fixtureImagePath) + const fixtureAsBinary = fileContent.toString("binary") + + const { writeStream, promise } = await s3Service.getUploadStream({ + filename: "catphoto-stream.jpg", + mimeType: "image/jpeg", + access: "public", + }) + + writeStream.write(fileContent) + writeStream.end() + + const resp = await promise + + expect(resp).toEqual({ + key: expect.stringMatching(/tests\/catphoto-stream.*\.jpg/), + url: expect.stringMatching(/https?:\/\/.*\.jpg/), + }) + + const urlResp = await axios.get(resp.url).catch((e) => e.response) + expect(urlResp.status).toEqual(200) + + const signedUrl = await s3Service.getPresignedDownloadUrl({ + fileKey: resp.key, + }) + + const signedUrlFile = Buffer.from( + await axios + .get(signedUrl, { responseType: "arraybuffer" }) + .then((r) => r.data) + ) + + expect(signedUrlFile.toString("binary")).toEqual(fixtureAsBinary) + + await s3Service.delete({ fileKey: resp.key }) + }) }) diff --git a/packages/modules/providers/file-s3/package.json b/packages/modules/providers/file-s3/package.json index b460bb05e3..54bcf993f3 100644 --- a/packages/modules/providers/file-s3/package.json +++ b/packages/modules/providers/file-s3/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.556.0", + "@aws-sdk/lib-storage": "^3.556.0", "@aws-sdk/s3-request-presigner": "^3.556.0", "ulid": "^2.3.0" }, diff --git a/packages/modules/providers/file-s3/src/services/s3-file.ts b/packages/modules/providers/file-s3/src/services/s3-file.ts index f617a722f8..83d76ac66b 100644 --- a/packages/modules/providers/file-s3/src/services/s3-file.ts +++ b/packages/modules/providers/file-s3/src/services/s3-file.ts @@ -7,6 +7,7 @@ import { S3Client, S3ClientConfigType, } from "@aws-sdk/client-s3" +import { Upload } from "@aws-sdk/lib-storage" import { getSignedUrl } from "@aws-sdk/s3-request-presigner" import { FileTypes, @@ -18,7 +19,7 @@ import { MedusaError, } from "@medusajs/framework/utils" import path from "path" -import { Readable } from "stream" +import { PassThrough, Readable, Writable } from "stream" import { ulid } from "ulid" type InjectedDependencies = { @@ -165,6 +166,53 @@ export class S3FileService extends AbstractFileProviderService { } } + async getUploadStream(fileData: FileTypes.ProviderUploadStreamDTO): Promise<{ + writeStream: Writable + promise: Promise + url: string + fileKey: string + }> { + if (!fileData.filename) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `No filename provided` + ) + } + + const parsedFilename = path.parse(fileData.filename) + const fileKey = `${this.config_.prefix}${parsedFilename.name}-${ulid()}${ + parsedFilename.ext + }` + + const pass = new PassThrough() + const upload = new Upload({ + client: this.client_, + params: { + ACL: fileData.access === "public" ? "public-read" : "private", + Bucket: this.config_.bucket, + Key: fileKey, + Body: pass, + ContentType: fileData.mimeType, + CacheControl: this.config_.cacheControl, + Metadata: { + "original-filename": encodeURIComponent(fileData.filename), + }, + }, + }) + + const promise = upload.done().then(() => ({ + url: `${this.config_.fileUrl}/${fileKey}`, + key: fileKey, + })) + + return { + writeStream: pass, + promise, + url: `${this.config_.fileUrl}/${fileKey}`, + fileKey, + } + } + async delete( files: FileTypes.ProviderDeleteFileDTO | FileTypes.ProviderDeleteFileDTO[] ): Promise { @@ -207,7 +255,7 @@ export class S3FileService extends AbstractFileProviderService { Key: `${fileData.fileKey}`, }) - return await getSignedUrl(this.client_, command, { + return await getSignedUrl(this.client_ as any, command as any, { expiresIn: this.config_.downloadFileDuration, }) } @@ -238,7 +286,7 @@ export class S3FileService extends AbstractFileProviderService { Key: fileKey, }) - const signedUrl = await getSignedUrl(this.client_, command, { + const signedUrl = await getSignedUrl(this.client_ as any, command as any, { expiresIn: fileData.expiresIn ?? DEFAULT_UPLOAD_EXPIRATION_DURATION_SECONDS, }) diff --git a/yarn.lock b/yarn.lock index ecedfd8e34..df1b7f2376 100644 --- a/yarn.lock +++ b/yarn.lock @@ -580,6 +580,23 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/lib-storage@npm:^3.556.0": + version: 3.948.0 + resolution: "@aws-sdk/lib-storage@npm:3.948.0" + dependencies: + "@smithy/abort-controller": ^4.2.5 + "@smithy/middleware-endpoint": ^4.3.14 + "@smithy/smithy-client": ^4.9.10 + buffer: 5.6.0 + events: 3.3.0 + stream-browserify: 3.0.0 + tslib: ^2.6.2 + peerDependencies: + "@aws-sdk/client-s3": ^3.948.0 + checksum: 11edd46ee1f2ef74efbf9b5b422f77d2c792693bd90051ef3bda6ab36e76f2b6533f531df5d390da31c757efcc98b0513d70a14a28561eefa13e641847d1831d + languageName: node + linkType: hard + "@aws-sdk/middleware-bucket-endpoint@npm:3.936.0": version: 3.936.0 resolution: "@aws-sdk/middleware-bucket-endpoint@npm:3.936.0" @@ -3580,6 +3597,7 @@ __metadata: resolution: "@medusajs/file-s3@workspace:packages/modules/providers/file-s3" dependencies: "@aws-sdk/client-s3": ^3.556.0 + "@aws-sdk/lib-storage": ^3.556.0 "@aws-sdk/s3-request-presigner": ^3.556.0 "@medusajs/framework": 2.12.2 ulid: ^2.3.0 @@ -13465,7 +13483,7 @@ __metadata: languageName: node linkType: hard -"base64-js@npm:^1.2.0, base64-js@npm:^1.3.1": +"base64-js@npm:^1.0.2, base64-js@npm:^1.2.0, base64-js@npm:^1.3.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" checksum: f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf @@ -13769,6 +13787,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:5.6.0": + version: 5.6.0 + resolution: "buffer@npm:5.6.0" + dependencies: + base64-js: ^1.0.2 + ieee754: ^1.1.4 + checksum: 07037a0278b07fbc779920f1ba1b473933ffb4a2e2f7b387c55daf6ac64a05b58c27da9e85730a4046e8f97a49f8acd9f7bf89605c0a4dfda88ebfb7e08bfe4a + languageName: node + linkType: hard + "buffer@npm:^5.2.1, buffer@npm:^5.5.0": version: 5.7.1 resolution: "buffer@npm:5.7.1" @@ -16661,7 +16689,7 @@ __metadata: languageName: node linkType: hard -"events@npm:^3.3.0": +"events@npm:3.3.0, events@npm:^3.3.0": version: 3.3.0 resolution: "events@npm:3.3.0" checksum: d6b6f2adbccbcda74ddbab52ed07db727ef52e31a61ed26db9feb7dc62af7fc8e060defa65e5f8af9449b86b52cc1a1f6a79f2eafcf4e62add2b7a1fa4a432f6 @@ -18278,7 +18306,7 @@ __metadata: languageName: node linkType: hard -"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": +"ieee754@npm:^1.1.13, ieee754@npm:^1.1.4, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" checksum: b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb @@ -24170,7 +24198,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^3.0.2, readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0, readable-stream@npm:^3.6.2": +"readable-stream@npm:^3.0.2, readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.5.0, readable-stream@npm:^3.6.0, readable-stream@npm:^3.6.2": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" dependencies: @@ -25791,6 +25819,16 @@ __metadata: languageName: node linkType: hard +"stream-browserify@npm:3.0.0": + version: 3.0.0 + resolution: "stream-browserify@npm:3.0.0" + dependencies: + inherits: ~2.0.4 + readable-stream: ^3.5.0 + checksum: ec3b975a4e0aa4b3dc5e70ffae3fc8fd29ac725353a14e72f213dff477b00330140ad014b163a8cbb9922dfe90803f81a5ea2b269e1bbfd8bd71511b88f889ad + languageName: node + linkType: hard + "stream-shift@npm:^1.0.0, stream-shift@npm:^1.0.2": version: 1.0.3 resolution: "stream-shift@npm:1.0.3"