diff --git a/CHANGELOG.md b/CHANGELOG.md index f2f1168e5..0ee0c4a21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- A new feature called Export module; allowing to report all records into a single file. + ## [3.5.5] - 2022-11-11 ### Fixed - Allow creation of a return for orders placed with a ` PICKUP_POINT` as customer address. diff --git a/docs/README.md b/docs/README.md index f8b1412e1..f0a102898 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,6 +3,13 @@ Return app v3 is still in BETA. v2 will no longer be supported so please do not install it. Docs are WIP. + - Table of contents + - [Features](#features) + - [API](#api) + - [Customization](#customization) + - [Known issues](#known-issues) + - [Development](#development) + ## Description The **Return App** gives merchants the option to allow customers to request a return for their items and it gives them the ability to manage the Return Request Process on their store. @@ -15,7 +22,7 @@ Here a customer will be able to visualize the history, status and details of the ### Admin: Return Request List -In this section of the merchant's admin, merchants are capable of visualizing and managing all the return requests created by their customers. +In this section of the merchant's admin, merchants are capable of visualizing and managing all the return requests created by their customers, while also being able to export all or some records to a single file. ### Admin: Return Settings @@ -303,11 +310,17 @@ In order to apply CSS customizations in this and other blocks, follow the instru |'termsAndConditionsLink'| |'userCommentDetailsContainer'| -## Knowing issues +## Known issues - When a store has a process to create return invoices ([invoice type input](https://developers.vtex.com/vtex-rest-api/reference/invoicenotification)) outside the return app, the app will consider those items and they will not be able to be returned via the app. However when an item is already committed in a return request and an invoice is created considering that item with a invoice number different than the return request id, there will be more processed items to return then invoices items - It can be seen using the query `orderToReturnSummary` on GraphQL. - When installing the app in a workspace - or creating a new one - the app will not behavior as expected. This is due to the masterdata builder not creating a schema for that workspace automatically. To fix that, one can just link the app in the workspace using the toolbelt. Doing so, there will be a new masterdata schema related to that workspace and the app should work fine. +## Development + +### Export feature (Report) + +This app leverages the Report API to export all return documents into a single file. For this to work, we maintain a matrix transformation MAP inside **/node/report**. If any key or value of the object is changed, next time any account that opens the module will have their map created/updated. The ID is the identifier we use to compare them, there's no need of changing it every time we modify the object. + --- Documentation for v2 [here](https://github.com/vtex-apps/return-app/tree/v2). diff --git a/graphql/schema.graphql b/graphql/schema.graphql index a99d4aafb..0539cd1a6 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -30,6 +30,7 @@ type Query { @withUserProfile @cacheControl(scope: PRIVATE, maxAge: SHORT) nearestPickupPoints(lat: String!, long: String!): NearPickupPointQueryResponse + exportStatus: ExportReportData @cacheControl(scope: PRIVATE, maxAge: ZERO) } type Mutation { @@ -42,4 +43,7 @@ type Mutation { comment: ReturnRequestCommentInput refundData: RefundDataInput ): ReturnRequestResponse @withUserProfile + exportReturnRequests( + exportData: ExportReturnRequestsInput! + ): ExportReportData @withUserProfile } diff --git a/graphql/types/ExportReturnRequests.graphql b/graphql/types/ExportReturnRequests.graphql new file mode 100644 index 000000000..dafc3ab82 --- /dev/null +++ b/graphql/types/ExportReturnRequests.graphql @@ -0,0 +1,57 @@ +type ExportReportData { + id: String + inProgress: Boolean + percentageProcessed: Float + requestedBy: String + completedDate: String + """ + Masterdata _WHERE filter + """ + selectedFilters: String + downloadLink: String + """ + Download link expires 6 hours after completed + """ + staleLink: Boolean + lastErrorMessage: String +} + +input ExportReturnRequestsInput { + fileFormat: ExportFormatInput! + """ + Masterdata _WHERE filter + """ + documentsFilter: String + """ + If not provided, the file will only be accesible via export status download link + """ + deliveryConfiguration: DeliveryConfigurationData +} + +enum ExportFormatInput { + XLSX + """ + UNTESTED + """ + CSV + """ + UNTESTED + """ + JSON +} + +input DeliveryConfigurationData { + type: DeliveryTypeInput! + value: String! +} + +enum DeliveryTypeInput { + """ + Send file to endpoint + """ + ENDPOINT + """ + Send file to email + """ + EMAIL +} diff --git a/messages/context.json b/messages/context.json index 428dda41d..40dd052eb 100644 --- a/messages/context.json +++ b/messages/context.json @@ -329,5 +329,26 @@ "return-app.return-request-details.cancellation.modal.adminAllow": "Text message for allowing a cancellation for admins", "return-app.return-request-details.cancellation.modal.adminRefuse": "Text message for refusing a cancellation for admins", "return-app.return-request-details.cancellation.modal.storeAllow": "Text message for allowing a cancellation for store users", - "return-app.return-request-details.cancellation.modal.storeRefuse": "Text message for refusing a cancellation for store users" + "return-app.return-request-details.cancellation.modal.storeRefuse": "Text message for refusing a cancellation for store users", + "admin/return-app.export-module.modal-open": "Open export modal CTA", + "admin/return-app.export-module.modal-title": "Export modal title", + "admin/return-app.export-module.modal-close": "Export modal close CTA", + "admin/return-app.export-module.content.first-paragraph": "Export explanation content, first paragraph", + "admin/return-app.export-module.content.second-paragraph": "Export explanation content, second paragraph", + "admin/return-app.export-module.content.warning-paragraph": "Export explanation content, warning paragraph", + "admin/return-app.export-module.content.format-disclaimer": "Export file format disclaimer", + "admin/return-app.export-module.content.skip-disclaimer": "Export skip record disclaimer", + "admin/return-app.export-module.report.status-title": "Report status section title", + "admin/return-app.export-module.report.download-cta": "Export file download CTA", + "admin/return-app.export-module.report.status-requestedBy": "Export status, requested-by field", + "admin/return-app.export-module.report.status-completedDate": "Export status, completed-date field", + "admin/return-app.export-module.report.status-filterRange": "Export status, filter-range field", + "admin/return-app.export-module.report.status-tag.error": "Export status error tag", + "admin/return-app.export-module.report.status-tag.inProgress": "Export status in progress tag", + "admin/return-app.export-module.report.status-tag.ready": "Export status ready tag", + "admin/return-app.export-module.report.form-title": "Report form section title", + "admin/return-app.export-module.report.form-allRequests-toggle": "Export form toggle label for filters", + "admin/return-app.export-module.report.form-email-toggle": "Export form toggle label for email", + "admin/return-app.export-module.report.form-email-placeholder": "Export form input placeholder for email", + "admin/return-app.export-module.report.form-cta": "Export form CTA" } diff --git a/messages/en.json b/messages/en.json index 03bbc0c2f..4e28b7d33 100644 --- a/messages/en.json +++ b/messages/en.json @@ -329,5 +329,26 @@ "return-app.return-request-details.cancellation.modal.adminAllow": "

Attention: This request's status will be set to CANCELLED.

This will notify the user via email and allow them to create a new return request with the cancelled request's items.

If that is not your intention, set the request as DENIED.

", "return-app.return-request-details.cancellation.modal.adminRefuse": "

Sorry, it's not possible to cancel this request due to its current status.

", "return-app.return-request-details.cancellation.modal.storeAllow": "

Cancelling this request will allow the current items to be used in a new return request.

This action is irreversible.

", - "return-app.return-request-details.cancellation.modal.storeRefuse": "

Sorry, you need to contact the support team to cancel this request due to its current status.

" + "return-app.return-request-details.cancellation.modal.storeRefuse": "

Sorry, you need to contact the support team to cancel this request due to its current status.

", + "admin/return-app.export-module.modal-open": "EXPORT RETURNS", + "admin/return-app.export-module.modal-title": "Export module", + "admin/return-app.export-module.modal-close": "CLOSE", + "admin/return-app.export-module.content.first-paragraph": "The Export module is the system responsible for merging all return requests into a single file called report, this can be then sent to an email or downloaded directly from this module", + "admin/return-app.export-module.content.second-paragraph": "Keep in mind that download links are not permanent, they expire after 6 hours following their availability", + "admin/return-app.export-module.content.warning-paragraph": "As the export process runs in the backround, if you only chose to generate a download link, you can close this page and check back later; if not, you'll be alerted via email", + "admin/return-app.export-module.content.format-disclaimer": "Supported formats:", + "admin/return-app.export-module.content.skip-disclaimer": "Documents containing errors will be skipped", + "admin/return-app.export-module.report.status-title": "Last report", + "admin/return-app.export-module.report.download-cta": "Download link", + "admin/return-app.export-module.report.status-requestedBy": "Requested by:", + "admin/return-app.export-module.report.status-completedDate": "Completed date:", + "admin/return-app.export-module.report.status-filterRange": "Filter range:", + "admin/return-app.export-module.report.status-tag.error": "Error, please try again", + "admin/return-app.export-module.report.status-tag.inProgress": "In Progress", + "admin/return-app.export-module.report.status-tag.ready": "Ready", + "admin/return-app.export-module.report.form-title": "New report", + "admin/return-app.export-module.report.form-allRequests-toggle": "All requests", + "admin/return-app.export-module.report.form-email-toggle": "Send file to email", + "admin/return-app.export-module.report.form-email-placeholder": "Recipient email", + "admin/return-app.export-module.report.form-cta": "GENERATE" } diff --git a/node/clients/index.ts b/node/clients/index.ts index 2a1a2d876..fd2145252 100644 --- a/node/clients/index.ts +++ b/node/clients/index.ts @@ -1,6 +1,7 @@ import { IOClients, Sphinx } from '@vtex/api' import { vbaseFor, masterDataFor } from '@vtex/clients' import { ReturnAppSettings, ReturnRequest } from 'vtex.return-app' +import type { ExportReportData } from 'vtex.return-app' import { Catalog } from './catalog' import { OMSCustom as OMS } from './oms' @@ -9,8 +10,15 @@ import { MailClient } from './mail' import Checkout from './checkout' import { VtexId } from './vtexId' import { CatalogGQL } from './catalogGQL' +import { ReturnRequestReport } from './report' + +type ExportReportDataVBase = Pick< + ExportReportData, + 'id' | 'selectedFilters' | 'requestedBy' +> const ReturnAppSettings = vbaseFor('appSettings') +const ExportReport = vbaseFor('exportReport') const ReturnRequest = masterDataFor('returnRequest') export class Clients extends IOClients { @@ -22,6 +30,10 @@ export class Clients extends IOClients { return this.getOrSet('appSettings', ReturnAppSettings) } + public get exportReport() { + return this.getOrSet('exportReport', ExportReport) + } + public get catalog() { return this.getOrSet('catalog', Catalog) } @@ -34,6 +46,10 @@ export class Clients extends IOClients { return this.getOrSet('returnRequest', ReturnRequest) } + public get report() { + return this.getOrSet('report', ReturnRequestReport) + } + public get giftCard() { return this.getOrSet('giftCard', GiftCard) } diff --git a/node/clients/report.ts b/node/clients/report.ts new file mode 100644 index 000000000..f5f05cb2b --- /dev/null +++ b/node/clients/report.ts @@ -0,0 +1,42 @@ +import type { InstanceOptions, IOContext } from '@vtex/api' +import { JanusClient } from '@vtex/api' + +const baseURL = '/api/report' + +const routes = { + masterdata: `${baseURL}/masterdata`, + inProgress: `${baseURL}/inprogress`, + report: (id: string) => `${baseURL}/${id}`, + map: (id: string) => `${baseURL}/map/${id}`, +} + +/** + * API used to create a complete report of a masterdata entity + */ +export class ReturnRequestReport extends JanusClient { + constructor(context: IOContext, options?: InstanceOptions) { + super(context, { + ...options, + headers: { + VtexIdclientAutCookie: context.authToken, + ...(options?.headers ?? {}), + }, + }) + } + + public getMap = (mapId: string) => this.http.get(routes.map(mapId)) + + public createOrUpdateMap = (map: ReportMap) => + this.http.put(routes.map(map.id), map) + + public deleteMap = (mapId: string) => this.http.delete(routes.map(mapId)) + + public inProgressReports = () => + this.http.get(routes.inProgress) + + public getReport = (reportId: string) => + this.http.get(routes.report(reportId)) + + public generateReport = (reportConfig: MasterdataReportsConfig) => + this.http.post(routes.masterdata, reportConfig) +} diff --git a/node/report/json_return-request-map.ts b/node/report/json_return-request-map.ts new file mode 100644 index 000000000..0bc6af1d8 --- /dev/null +++ b/node/report/json_return-request-map.ts @@ -0,0 +1,318 @@ +/** + * @todo + * Test translationPrefix for the status column by creating new messages + */ +export const localMap: ReportMap = { + id: '7a42a323-1faa-11ed-835d-16acdede38c5', + name: 'Return Requests', + path: 'items', + domain: null, + skipRecordOnError: false, + isGlobal: false, + columns: [ + { + header: 'status', + query: 'status', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'order_id', + query: 'orderId', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sequence_number', + query: 'sequenceNumber', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'date_submitted', + query: 'dateSubmitted', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'refundable_amount', + query: 'refundableAmount', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'refundable_amount_items', + query: "refundableAmountTotals[id='items'].value", + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'refundable_amount_shipping', + query: "refundableAmountTotals[id='shipping'].value", + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'refundable_amount_tax', + query: "refundableAmountTotals[id='tax'].value", + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'customer_user_id', + query: 'customerProfileData.userId', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'customer_name', + query: 'customerProfileData.name', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'customer_email', + query: 'customerProfileData.email', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'customer_phone_number', + query: 'customerProfileData.phoneNumber', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'pickup_address_id', + query: 'pickupReturnData.addressId', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'pickup_address', + query: 'pickupReturnData.address', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'pickup_city', + query: 'pickupReturnData.city', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'pickup_state', + query: 'pickupReturnData.state', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'pickup_country', + query: 'pickupReturnData.country', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'pickup_zip_code', + query: 'pickupReturnData.zipCode', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'pickup_address_type', + query: 'pickupReturnData.addressType', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'pickup_return_label', + query: "pickupReturnData.( returnLabel ? returnLabel : 'NA' )", + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'refund_payment_method', + query: 'refundPaymentData.refundPaymentMethod', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'refund_payment_iban', + query: "refundPaymentData.( iban ? iban : 'NA' )", + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'refund_payment_account_holder_name', + query: + "refundPaymentData.( accountHolderName ? accountHolderName : 'NA' )", + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'refund_payment_automatic_refund', + query: + "refundPaymentData.( automaticallyRefundPaymentMethod ? automaticallyRefundPaymentMethod : 'NA' )", + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'currency_code', + query: 'cultureInfoData.currencyCode', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'locale', + query: 'cultureInfoData.locale', + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'refund_status_new', + query: + "($a := refundStatusData.[status = 'new'].Product; $a ? $a : 'NA')", + usePath: false, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_order_item_index', + query: 'orderItemIndex', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_id', + query: 'id', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_product_id', + query: 'productId', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_ref_id', + query: 'refId', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_name', + query: 'name', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_localized_name', + query: "( localizedName ? localizedName : 'NA' )", + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_image', + query: 'imageUrl', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_seller_id', + query: 'sellerId', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_seller_name', + query: 'sellerName', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_quantity', + query: 'quantity', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_unit_multiplier', + query: 'unitMultiplier', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_condition', + query: 'condition', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_selling_price', + query: 'sellingPrice', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_tax', + query: 'tax', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_return_reason', + query: 'returnReason.reason', + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + { + header: 'sku_other_return_reson', + query: "returnReason.( otherReason ? otherReason : 'NA' )", + usePath: true, + translationPrefix: null, + defaultLanguage: null, + }, + ], +} diff --git a/node/resolvers/exportReturnRequests.ts b/node/resolvers/exportReturnRequests.ts new file mode 100644 index 000000000..a7ca4e78f --- /dev/null +++ b/node/resolvers/exportReturnRequests.ts @@ -0,0 +1,16 @@ +import type { + MutationExportReturnRequestsArgs, + ExportReportData, +} from 'vtex.return-app' + +import { exportReturnRequestsService } from '../services/exportReturnRequestsService' + +export const exportReturnRequests = async ( + _: unknown, + args: MutationExportReturnRequestsArgs, + ctx: Context +): Promise => { + const { exportData } = args + + return exportReturnRequestsService(ctx, exportData) +} diff --git a/node/resolvers/exportStatus.ts b/node/resolvers/exportStatus.ts new file mode 100644 index 000000000..2605cc654 --- /dev/null +++ b/node/resolvers/exportStatus.ts @@ -0,0 +1,11 @@ +import type { ExportReportData } from 'vtex.return-app' + +import { exportStatusService } from '../services/exportStatusService' + +export const exportStatus = async ( + _: unknown, + __: unknown, + ctx: Context +): Promise => { + return exportStatusService(ctx) +} diff --git a/node/resolvers/index.ts b/node/resolvers/index.ts index e462ffcbf..7e65cdb91 100644 --- a/node/resolvers/index.ts +++ b/node/resolvers/index.ts @@ -11,10 +11,13 @@ import { returnRequestList } from './returnRequestList' import { ReturnRequestResponse } from './ReturnRequestResponse' import { updateReturnRequestStatus } from './updateReturnRequestStatus' import { nearestPickupPoints } from './nearestPickupPoints' +import { exportStatus } from './exportStatus' +import { exportReturnRequests } from './exportReturnRequests' export const mutations = { createReturnRequest, updateReturnRequestStatus, + exportReturnRequests, ...settingsMutation, } @@ -26,6 +29,7 @@ export const queries = { returnRequest, returnRequestList, nearestPickupPoints, + exportStatus, } export const resolvers = { ReturnRequestResponse } diff --git a/node/services/exportReturnRequestsService.ts b/node/services/exportReturnRequestsService.ts new file mode 100644 index 000000000..070854278 --- /dev/null +++ b/node/services/exportReturnRequestsService.ts @@ -0,0 +1,67 @@ +import { ResolverError, UserInputError } from '@vtex/api' +import type { + ExportReturnRequestsInput, + ExportReportData, +} from 'vtex.return-app' + +import { verifyReportMap } from '../utils/verifyReportMap' +import { EXPORT_DATA_PATH } from '../utils/constants' +import { createReportConfig } from '../utils/createReportConfig' + +export const exportReturnRequestsService = async ( + ctx: Context, + args: ExportReturnRequestsInput +): Promise => { + const { + state: { userProfile }, + clients: { report: returnRequestReport, exportReport }, + } = ctx + + if (!args.fileFormat || args.fileFormat !== 'XLSX') { + throw new UserInputError( + 'File format not supported or specified. Supported formats: XLSX' + ) + } + + /* If the MAP is missing/outdated we update it from our local map found in ../report */ + await verifyReportMap(returnRequestReport) + + /* Creates the Report api body needed */ + const reportConfig = createReportConfig(ctx, args) + + try { + const report = await returnRequestReport.generateReport(reportConfig) + const { + id, + finished, + linkToDownload, + lastErrorMessage, + percentageProcessed, + completedDate, + } = report + + const documentsFilter = args.documentsFilter ?? null + + await exportReport.save(EXPORT_DATA_PATH, { + id, + selectedFilters: documentsFilter, + requestedBy: userProfile?.email ?? null, + }) + + return { + id, + inProgress: !finished, + percentageProcessed, + requestedBy: userProfile?.email ?? null, + completedDate, + selectedFilters: documentsFilter, + downloadLink: linkToDownload, + staleLink: false, + lastErrorMessage, + } + } catch (error) { + throw new ResolverError( + `An unexpected error ocurred while requesting a new report: ${error.message}` + ) + } +} diff --git a/node/services/exportStatusService.ts b/node/services/exportStatusService.ts new file mode 100644 index 000000000..668894ccd --- /dev/null +++ b/node/services/exportStatusService.ts @@ -0,0 +1,62 @@ +import { ResolverError } from '@vtex/api' +import type { ExportReportData } from 'vtex.return-app' + +import { EXPORT_DATA_PATH } from '../utils/constants' +import { isReportStale } from '../utils/dateHelpers' + +export const exportStatusService = async ( + ctx: Context +): Promise => { + const { + clients: { report: returnRequestReport, exportReport }, + } = ctx + + const reportReference = await exportReport.get(EXPORT_DATA_PATH, true) + + if (!reportReference?.id) { + return null + } + + const { id, selectedFilters, requestedBy } = reportReference + + try { + const report = await returnRequestReport.getReport(id) + const { + finished, + linkToDownload, + completedDate, + lastErrorMessage, + percentageProcessed, + } = report + + /** + * @bug + * A report can be in progress but completed and available. + * The reason is unknown, so we still allow the user to download the file + */ + const reportCompleted = + Boolean(linkToDownload) && percentageProcessed === 100 + + return { + id, + inProgress: !reportCompleted ?? finished === false, + percentageProcessed, + requestedBy: requestedBy ?? 'unknown', + completedDate, + selectedFilters: selectedFilters ?? null, + downloadLink: linkToDownload, + staleLink: linkToDownload ? isReportStale(completedDate) : null, + lastErrorMessage, + } + } catch (error) { + if (error.response.status === 404) { + await exportReport.save(EXPORT_DATA_PATH, {}) + + return null + } + + throw new ResolverError( + `An unexpected error ocurred while requesting export status: ${error.message}` + ) + } +} diff --git a/node/typings/report.d.ts b/node/typings/report.d.ts new file mode 100644 index 000000000..e8d0935d2 --- /dev/null +++ b/node/typings/report.d.ts @@ -0,0 +1,89 @@ +interface Report { + canceled: boolean + completedDate: string | null + email: string + enqueueDate: string + finished: boolean + id: string + lastUpdateTime: string | null + linkToDownload: string | null + outputType: string + recordsProcessed: number | null + percentageProcessed: number + recordsSum: number | null + startDate: string | null + zipped: boolean + lastErrorMessage: string | null + deliveredDate: string | null + language: string | null + utcTime: string | null + deliveryConfig: DeliveryConfig +} + +interface ReportField { + header: string + query: string + usePath: boolean + translationPrefix: string | null + defaultLanguage: string | null +} + +interface ReportMap { + id: string + isGlobal: boolean + path: string + name: string + skipRecordOnError: boolean + domain: string | null + columns: ReportField[] +} + +interface DeliveryEndpoint { + endpoint: string + type: 'Endpoint' +} + +interface DeliveryEmail { + templateName: string + email: string + /* Not a typo */ + type: 'EMail' +} + +interface DeliveryNone { + type: 'None' +} + +type ReportOutput = 'XLSX' | 'CSV' | 'JSON' + +type DeliveryConfig = DeliveryNone | DeliveryEmail | DeliveryEndpoint + +interface MasterdataReportsConfig { + mapId: string + mapIds: string[] + where: string + outputType: ReportOutput + zipped: boolean + entityName: string + schema: string + queryAllStores: boolean + deliveryConfig: DeliveryConfig +} + +interface MasterdataReportsResponse { + canceled: boolean + completedDate: string + email: string + enqueueDate: string + finished: boolean + id: string + lastUpdateTime: string + linkToDownload: string + outputType: string + percentageProcessed: number + recordsSum: number + startDate: string + zipped: boolean + lastErrorMessage: string + deliveryConfig: DeliveryConfig +} diff --git a/node/utils/constants.ts b/node/utils/constants.ts index 6ddfc5ddd..f3b10d810 100644 --- a/node/utils/constants.ts +++ b/node/utils/constants.ts @@ -6,6 +6,7 @@ import type { } from '../typings/mailClient' export const SETTINGS_PATH = 'app-settings' +export const EXPORT_DATA_PATH = 'export-data' export const ORDER_TO_RETURN_VALIDATON: Record< OrderToReturnValidation, diff --git a/node/utils/createReportConfig.ts b/node/utils/createReportConfig.ts new file mode 100644 index 000000000..965fa44ef --- /dev/null +++ b/node/utils/createReportConfig.ts @@ -0,0 +1,60 @@ +import { parseAppId } from '@vtex/api' +import type { ExportReturnRequestsInput } from 'vtex.return-app' + +import { localMap } from '../report/json_return-request-map' + +const BASE_CONFIG = { + DELIVERY: { + /* Not a typo */ + EMAIL: 'EMail', + NONE: 'None', + ENDPOINT: 'Endpoint', + }, + ENTITY: 'vtex_return_app_returnRequest', + FORMAT: { + XLSX: 'XLSX', + JSON: 'JSON', + CSV: 'CSV', + }, + TEMPLATE: 'report-report-finished', +} as const + +const versionDescriptor = (isProduction: boolean, workspace: string) => + isProduction ? '' : `-${workspace}` + +/** + * Creates the data required for the masterdata report api + * @info For the moment, we only support 'no delivery' and 'email' + * as delivery options; but can be upgraded to suppport an endpoint call + */ +export const createReportConfig = ( + ctx: Context, + args: ExportReturnRequestsInput +): MasterdataReportsConfig => { + const { + vtex: { production, workspace }, + } = ctx + + const { fileFormat, documentsFilter, deliveryConfiguration } = args + + const app = parseAppId(process.env.VTEX_APP_ID as string) + const schema = `${app.version}${versionDescriptor(production, workspace)}` + + return { + mapId: localMap.id, + mapIds: [localMap.id], + outputType: fileFormat ?? BASE_CONFIG.FORMAT.XLSX, + zipped: true, + where: documentsFilter ?? '', + entityName: BASE_CONFIG.ENTITY, + schema, + queryAllStores: true, + deliveryConfig: deliveryConfiguration + ? { + type: BASE_CONFIG.DELIVERY.EMAIL, + templateName: BASE_CONFIG.TEMPLATE, + email: deliveryConfiguration.value, + } + : { type: BASE_CONFIG.DELIVERY.NONE }, + } +} diff --git a/node/utils/dateHelpers.ts b/node/utils/dateHelpers.ts index f197622f9..bffd80372 100644 --- a/node/utils/dateHelpers.ts +++ b/node/utils/dateHelpers.ts @@ -19,3 +19,22 @@ export const isWithinMaxDaysToReturn = ( return new Date(orderCreationDate) > new Date(limitDateToReturn) } + +/** + * Compares a given ISO date vs today + * @returns if the report download link expired after 6 hours + */ +export const isReportStale = (completedDate: string | null) => { + if (!completedDate) return null + + const today = new Date() + const completed = new Date(completedDate) + + const difference = (today.getTime() - completed.getTime()) / 3600000 + + if (difference > 6) { + return true + } + + return false +} diff --git a/node/utils/verifyReportMap.ts b/node/utils/verifyReportMap.ts new file mode 100644 index 000000000..fd33a748b --- /dev/null +++ b/node/utils/verifyReportMap.ts @@ -0,0 +1,25 @@ +import assert from 'assert' + +import { ResolverError } from '@vtex/api' + +import { localMap } from '../report/json_return-request-map' +import type { ReturnRequestReport } from '../clients/report' + +export const verifyReportMap = async ( + reportClient: ReturnRequestReport +): Promise => { + try { + const remoteMap = await reportClient.getMap(localMap.id) + + /* Tests for deep equality, throws error with a custom message if it fails */ + assert.deepStrictEqual(remoteMap, localMap, 'unequal-maps') + } catch (error) { + if (error.message !== 'unequal-maps' && error.response.status !== 404) { + throw new ResolverError( + `An error ocurred while verifying if the local report map differs from to the remote one: ${error.message}` + ) + } + + await reportClient.createOrUpdateMap(localMap) + } +} diff --git a/react/admin/ExportReturnRequestsModal.tsx b/react/admin/ExportReturnRequestsModal.tsx new file mode 100644 index 000000000..3f87d2fd5 --- /dev/null +++ b/react/admin/ExportReturnRequestsModal.tsx @@ -0,0 +1,3 @@ +import ExportReturnRequestsModal from './ExportReturnRequestsModal/ExportModule' + +export default ExportReturnRequestsModal diff --git a/react/admin/ExportReturnRequestsModal/ExportModule.tsx b/react/admin/ExportReturnRequestsModal/ExportModule.tsx new file mode 100644 index 000000000..7616e0b48 --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/ExportModule.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import { + Button, + EXPERIMENTAL_Modal as Modal, + ButtonWithIcon, + utils, + IconDownload, +} from 'vtex.styleguide' +import { FormattedMessage } from 'react-intl' + +import { ExportProvider } from './provider/ExportProvider' +import Content from './components/Content' +import ReportContainer from './components/ReportContainer' + +const ExportModule = () => { + const { isOpen, onOpen, onClose } = utils.useDisclosure() + + return ( + <> + } + iconPosition="left" + variation="primary" + onClick={onOpen} + > + + + + + } + bottomBar={ +
+ +
+ } + > +
+
+ +
+
+ + + +
+
+
+ + ) +} + +export default ExportModule diff --git a/react/admin/ExportReturnRequestsModal/components/Content.tsx b/react/admin/ExportReturnRequestsModal/components/Content.tsx new file mode 100644 index 000000000..43b40d5ee --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/components/Content.tsx @@ -0,0 +1,52 @@ +import type { ReactElement } from 'react' +import React from 'react' +import { FormattedMessage } from 'react-intl' + +import { SUPPORTED_REPORT_FORMATS } from '../../../common/constants/returnsRequest' + +const Content = () => { + return ( + <> +

+ {chunks}, + }} + /> +

+

+ {chunks}, + }} + /> +

+
+ +
+

+ +   + {SUPPORTED_REPORT_FORMATS.map((format) => `.${format} `)} +

+

+ +

+ + ) +} + +export default Content diff --git a/react/admin/ExportReturnRequestsModal/components/ReportContainer.tsx b/react/admin/ExportReturnRequestsModal/components/ReportContainer.tsx new file mode 100644 index 000000000..7cf42023b --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/components/ReportContainer.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import { Divider } from 'vtex.styleguide' + +import ReportStatus from './ReportStatus' +import ReportForm from './ReportForm' +import { useExportModule } from '../hooks/useExportModule' +import ReportLoader from './ReportLoader' + +const ReportContainer = () => { + const { loadingStatus } = useExportModule() + + if (loadingStatus) { + return + } + + return ( + <> + +
+ +
+ + + ) +} + +export default ReportContainer diff --git a/react/admin/ExportReturnRequestsModal/components/ReportForm.tsx b/react/admin/ExportReturnRequestsModal/components/ReportForm.tsx new file mode 100644 index 000000000..3e74e7888 --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/components/ReportForm.tsx @@ -0,0 +1,182 @@ +import React, { useState } from 'react' +import type { FormEvent } from 'react' +import { + Button, + DatePicker, + Input, + IconUser, + Dropdown, + Toggle, +} from 'vtex.styleguide' +import { FormattedMessage } from 'react-intl' +import type { ExportReturnRequestsInput } from 'vtex.return-app' + +import { useExportModule } from '../hooks/useExportModule' +import { SUPPORTED_REPORT_FORMATS } from '../../../common/constants/returnsRequest' +import { createExportFilters } from '../../../utils/createExportFilters' + +const supportedOptions = SUPPORTED_REPORT_FORMATS.map((format) => { + return { value: format, label: format } +}) + +const initialFilters = { + from: '', + to: '', +} as ExportDateFilters + +type SupportedFilters = 'from' | 'to' | 'email' + +const ReportForm = () => { + const [exportAll, setExportPreference] = useState(true) + const [dateFilters, setFilters] = useState(initialFilters) + const [sendEmail, setEmailPreference] = useState(false) + const [recipientEmail, setRecipientEmail] = useState('') + + const { exportStatus, _handleExport, submitInProgress } = useExportModule() + + const { inProgress, lastErrorMessage } = exportStatus ?? {} + + const retryAvailable = inProgress && lastErrorMessage + + const fromDate = dateFilters.from ? new Date(dateFilters.from) : '' + const toDate = dateFilters.to ? new Date(dateFilters.to) : '' + + const missingInputs = + (!exportAll && !fromDate) || (sendEmail && !recipientEmail) + + const handleOnChange = (key: SupportedFilters, value: string) => { + if (key === 'to' || key === 'from') { + const filterDates = { + ...dateFilters, + [key]: value, + } + + if (!filterDates.to) { + filterDates.to = new Date().toISOString() + } + + if (!filterDates.from) { + filterDates.from = new Date(filterDates.to).toISOString() + } + + setFilters({ + ...filterDates, + }) + } + + if (key === 'email') { + setRecipientEmail(value) + } + } + + const handleSubmit = () => { + const exportData = { + fileFormat: 'XLSX', + documentsFilter: exportAll + ? null + : createExportFilters('dates', dateFilters), + deliveryConfiguration: sendEmail + ? { + type: 'EMAIL', + value: recipientEmail, + } + : null, + } as ExportReturnRequestsInput + + _handleExport(exportData) + } + + return ( + <> +

+ +

+
+ +
+ + } + checked={exportAll} + onChange={() => setExportPreference(!exportAll)} + /> +
+ + {(formattedMessage) => ( + + handleOnChange('from', new Date(date).toISOString()) + } + value={fromDate} + disabled={exportAll} + /> + )} + +
+
+ + {(formattedMessage) => ( + + handleOnChange('to', new Date(date).toISOString()) + } + value={toDate} + disabled={exportAll} + /> + )} + +
+ + {(formattedMessage) => ( + setEmailPreference(!sendEmail)} + /> + )} + +
+ + {(formattedMessage) => ( + } + disabled={!sendEmail} + onChange={(e: FormEvent) => + handleOnChange('email', e.currentTarget.value) + } + /> + )} + +
+
+ +
+ + ) +} + +export default ReportForm diff --git a/react/admin/ExportReturnRequestsModal/components/ReportLoader.tsx b/react/admin/ExportReturnRequestsModal/components/ReportLoader.tsx new file mode 100644 index 000000000..8e97b01a9 --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/components/ReportLoader.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import { SkeletonPiece } from 'vtex.my-account-commons' +import { Divider } from 'vtex.styleguide' + +const ReportLoader = () => { + return ( + <> +
+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + ) +} + +export default ReportLoader diff --git a/react/admin/ExportReturnRequestsModal/components/ReportStatus.tsx b/react/admin/ExportReturnRequestsModal/components/ReportStatus.tsx new file mode 100644 index 000000000..39e219442 --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/components/ReportStatus.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import { ButtonPlain, IconExternalLink } from 'vtex.styleguide' +import { FormattedMessage, FormattedDate } from 'react-intl' + +import { useExportModule } from '../hooks/useExportModule' +import TagStatus from './TagStatus' + +const ReportStatus = () => { + const { exportStatus } = useExportModule() + + const { + inProgress, + requestedBy, + completedDate, + selectedFilters, + downloadLink, + staleLink, + } = exportStatus ?? {} + + const disableDownload = inProgress || !downloadLink || staleLink + + return ( + <> +

+ +

+
+

+ false : downloadLink} + disabled={disableDownload} + target="_blank" + > + +   + + +

+ + + +
+

+ + +   + + {requestedBy ?? 'N/A'} +

+

+ + +   + + {completedDate ? ( + + ) : ( + 'N/A' + )} +

+

+ + +   + + {selectedFilters ?? 'N/A'} +

+ + ) +} + +export default ReportStatus diff --git a/react/admin/ExportReturnRequestsModal/components/TagStatus.tsx b/react/admin/ExportReturnRequestsModal/components/TagStatus.tsx new file mode 100644 index 000000000..588bc9540 --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/components/TagStatus.tsx @@ -0,0 +1,52 @@ +import React from 'react' +import { Tag, Spinner } from 'vtex.styleguide' +import { FormattedMessage } from 'react-intl' + +import { useExportModule } from '../hooks/useExportModule' +import { TAG_STATUSES } from '../provider/ExportProvider' + +const TagStatus = () => { + const { exportStatus, tagStatus } = useExportModule() + + const { percentageProcessed } = exportStatus ?? {} + + /* Parts of the report api flow are the 'Delivery' and 'Merge' stages. + This can represent 100% records processed, but not that the report is finished. + Representing 100% in the UI is not intuitive */ + const maxPercievedPercentage = + Boolean(percentageProcessed) && percentageProcessed === 100 + ? 99 + : percentageProcessed + + switch (tagStatus) { + case TAG_STATUSES.ERROR: + return ( + + + + ) + + case TAG_STATUSES.INPROGRESS: + return ( + + + +   + +   + {maxPercievedPercentage ? ~~maxPercievedPercentage : 0}% + + + ) + + default: + case TAG_STATUSES.READY: + return ( + + + + ) + } +} + +export default TagStatus diff --git a/react/admin/ExportReturnRequestsModal/graphql/exportReturnRequests.gql b/react/admin/ExportReturnRequestsModal/graphql/exportReturnRequests.gql new file mode 100644 index 000000000..3a9bd5239 --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/graphql/exportReturnRequests.gql @@ -0,0 +1,8 @@ +#import './exportStatusFragment.gql' + +mutation exportReturnRequests($exportData: ExportReturnRequestsInput!) { + exportReturnRequests(exportData: $exportData) + @context(provider: "vtex.return-app") { + ...ExportReportDataAdminFragment + } +} diff --git a/react/admin/ExportReturnRequestsModal/graphql/exportStatusFragment.gql b/react/admin/ExportReturnRequestsModal/graphql/exportStatusFragment.gql new file mode 100644 index 000000000..52d839909 --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/graphql/exportStatusFragment.gql @@ -0,0 +1,11 @@ +fragment ExportReportDataAdminFragment on ExportReportData { + id + inProgress + percentageProcessed + requestedBy + completedDate + selectedFilters + downloadLink + staleLink + lastErrorMessage +} diff --git a/react/admin/ExportReturnRequestsModal/graphql/getExportStatus.gql b/react/admin/ExportReturnRequestsModal/graphql/getExportStatus.gql new file mode 100644 index 000000000..b7f4f2aa4 --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/graphql/getExportStatus.gql @@ -0,0 +1,7 @@ +#import './exportStatusFragment.gql' + +query exportStatus { + exportStatus @context(provider: "vtex.return-app") { + ...ExportReportDataAdminFragment + } +} diff --git a/react/admin/ExportReturnRequestsModal/hooks/useExportModule.ts b/react/admin/ExportReturnRequestsModal/hooks/useExportModule.ts new file mode 100644 index 000000000..9c9d27851 --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/hooks/useExportModule.ts @@ -0,0 +1,5 @@ +import { useContext } from 'react' + +import { ExportContext } from '../provider/ExportProvider' + +export const useExportModule = () => useContext(ExportContext) diff --git a/react/admin/ExportReturnRequestsModal/provider/ExportProvider.tsx b/react/admin/ExportReturnRequestsModal/provider/ExportProvider.tsx new file mode 100644 index 000000000..a19a237bb --- /dev/null +++ b/react/admin/ExportReturnRequestsModal/provider/ExportProvider.tsx @@ -0,0 +1,143 @@ +import React, { createContext, useState, useEffect } from 'react' +import type { FC } from 'react' +import { useQuery, useMutation } from 'react-apollo' +import type { ApolloError } from 'apollo-client' +import type { + ExportReturnRequestsInput, + ExportReportData, + MutationExportReturnRequestsArgs, +} from 'vtex.return-app' + +import EXPORT_RETURN_REQUESTS from '../graphql/exportReturnRequests.gql' +import GET_EXPORT_STATUS from '../graphql/getExportStatus.gql' + +interface ExportContextInterface { + exportStatus?: ExportReportData | null + loadingStatus: boolean + submitInProgress: boolean + tagStatus: ExportStatusValues + error?: ApolloError + _handleExport: (exportData: ExportReturnRequestsInput) => void +} + +export const ExportContext = createContext( + {} as ExportContextInterface +) + +const STATUS_POLLING_MS = 2000 + +export const TAG_STATUSES = { + READY: 'status-ready', + INPROGRESS: 'status-in-progress', + ERROR: 'status-error', +} as const + +type ExportStatusKeys = keyof typeof TAG_STATUSES +type ExportStatusValues = typeof TAG_STATUSES[ExportStatusKeys] + +export const ExportProvider: FC = ({ children }) => { + /** + * Sadly the notify network feature for Apollo only announces when + * a polling is IN PROGRESS, and not when we are in a 'polling' state + */ + const [isPolling, setPollingStatus] = useState(false) + const [tagStatus, setTagStatus] = useState( + TAG_STATUSES.READY + ) + + const { + data, + loading: loadingStatus, + error, + startPolling, + stopPolling, + updateQuery, + } = useQuery<{ + exportStatus: ExportReportData | null + }>(GET_EXPORT_STATUS, { fetchPolicy: 'network-only' }) + + const { exportStatus } = data ?? {} + const { id, inProgress, lastErrorMessage } = exportStatus ?? {} + + const [exportReturnRequests, { loading: submitInProgress }] = useMutation< + { + exportReturnRequests: ExportReportData + }, + MutationExportReturnRequestsArgs + >(EXPORT_RETURN_REQUESTS) + + useEffect(() => { + if (error || lastErrorMessage) { + setTagStatus(TAG_STATUSES.ERROR) + + return + } + + if ((!id || !inProgress) && !error && !lastErrorMessage) { + setTagStatus(TAG_STATUSES.READY) + + return + } + + if (inProgress) { + setTagStatus(TAG_STATUSES.INPROGRESS) + } + }, [error, id, inProgress, lastErrorMessage]) + + useEffect(() => { + if ( + (isPolling && inProgress === false) || + (isPolling && lastErrorMessage) + ) { + setPollingStatus(false) + stopPolling() + } + + if (!isPolling && inProgress && !lastErrorMessage) { + setPollingStatus(true) + startPolling(STATUS_POLLING_MS) + } + }, [isPolling, stopPolling, lastErrorMessage, startPolling, inProgress]) + + const _handleExport = async (exportData: ExportReturnRequestsInput) => { + try { + const { errors, data: mutationData } = await exportReturnRequests({ + variables: { + exportData, + }, + }) + + if (!mutationData || errors) { + throw new Error( + `An unexpected error ocurred: ${ + errors ? errors.toString() : 'No mutation data received' + }` + ) + } + + updateQuery(() => ({ + exportStatus: { ...mutationData.exportReturnRequests }, + })) + setPollingStatus(true) + startPolling(STATUS_POLLING_MS) + } catch (e) { + console.error(e) + setTagStatus(TAG_STATUSES.ERROR) + } + } + + return ( + + {children} + + ) +} diff --git a/react/admin/ReturnList/ReturnListContainer.tsx b/react/admin/ReturnList/ReturnListContainer.tsx index d83695ec1..fde61d3dc 100644 --- a/react/admin/ReturnList/ReturnListContainer.tsx +++ b/react/admin/ReturnList/ReturnListContainer.tsx @@ -3,6 +3,7 @@ import { Layout, PageHeader, PageBlock } from 'vtex.styleguide' import { FormattedMessage } from 'react-intl' import ListTable from '../../common/components/returnList/ListTable' +import ExportReturnRequestsModal from '../ExportReturnRequestsModal' export const AdminReturnList = () => { return ( @@ -16,7 +17,9 @@ export const AdminReturnList = () => { subtitle={ } - /> + > + + } > diff --git a/react/common/constants/returnsRequest.ts b/react/common/constants/returnsRequest.ts index 09fe0d9af..34da7b3b6 100644 --- a/react/common/constants/returnsRequest.ts +++ b/react/common/constants/returnsRequest.ts @@ -120,3 +120,9 @@ export function getReasonOptions( }, ] } + +/** + * The report api supports either + * XLSX / JSON / CSV + */ +export const SUPPORTED_REPORT_FORMATS = ['XLSX'] as const diff --git a/react/typings/vtex.return-app.d.ts b/react/typings/vtex.return-app.d.ts index 1431eb23b..1be90cd12 100644 --- a/react/typings/vtex.return-app.d.ts +++ b/react/typings/vtex.return-app.d.ts @@ -12,3 +12,12 @@ interface ItemToReturn { type MaybeGlobal = T | null type GeoCoordinates = Array> + +interface ExportDateFilters { + from: string + to: string +} + +type ExportFiltersType = 'dates' + +type ExportFiltersData = ExportDateFilters diff --git a/react/utils/createExportFilters.ts b/react/utils/createExportFilters.ts new file mode 100644 index 000000000..ebbae12e2 --- /dev/null +++ b/react/utils/createExportFilters.ts @@ -0,0 +1,18 @@ +/** + * The report API accepts a string that feeds the masterdata's _WHERE field directly. + * This function should resolve any desireable filter into it's SQL counterpart. + * For the moment we only suppport filtering by dates. + */ +export function createExportFilters( + exportType: ExportFiltersType, + exportFilters: ExportFiltersData +) { + if (exportType === 'dates') { + return `dateSubmitted between ${exportFilters.from.substring( + 0, + 10 + )} AND ${exportFilters.to.substring(0, 10)}` + } + + return null +}