From 2b30e8bd39b46c170b7091afa6954b885cebc19a Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Fri, 28 Aug 2026 17:31:02 +0200 Subject: [PATCH] init --- .env.example | 133 + .gitignore | 16 + .prettierrc | 13 + Readme.md | 65 + backend/.dockerignore | 8 + backend/Dockerfile.dev | 13 + backend/Dockerfile.prod | 15 + backend/eslint.config.mjs | 44 + backend/nest-cli.json | 12 + backend/package-lock.json | 11388 ++++++++++++++++ backend/package.json | 79 + backend/src/AppModule.ts | 107 + backend/src/config/index.ts | 224 + backend/src/config/throttleProfiles.ts | 15 + backend/src/config/uploadPaths.ts | 56 + backend/src/config/validate.ts | 351 + backend/src/consts/shopSurfaceHeader.ts | 1 + .../src/consts/storefrontOrderPageAnchor.ts | 5 + .../consts/storefrontOrderRefreshSection.ts | 5 + backend/src/consts/xmrAtomicPerXmr.ts | 3 + backend/src/database/DataSource.ts | 5 + .../1777811112018-init-products-crud.ts | 27 + ...47115870-make-product-as-draft-initialy.ts | 21 + ...1779658508834-add-physical-product-type.ts | 29 + ...721786152-remove-price-unit-product-col.ts | 17 + .../1780672489601-add-discount-code-entity.ts | 17 + ...nstead-of-unique-constraint-on-discount.ts | 19 + ...-add-many-to-many-discounts-to-products.ts | 35 + .../1781785655067-add-product-categories.ts | 35 + .../1781849627565-add-discount-to-category.ts | 35 + ...782151519819-add-product-variants-table.ts | 27 + ...52984862-add-discount-code-variants-m2m.ts | 35 + ...ve-digital-stock-items-to-variant-scope.ts | 21 + .../1782839159240-remove-default-variant.ts | 17 + .../1783206651562-add-variant-images.ts | 21 + .../1783522977172-product-type-rework.ts | 35 + ...610487305-add-digital-stock-attachments.ts | 21 + .../1783701820648-remove-soft-delete-cols.ts | 25 + .../1783806278430-add-shop-settings.ts | 15 + .../1783912100000-add-shipping-note.ts | 13 + .../1784030873071-add-commerce-tables.ts | 161 + ...00000-add-order-staff-chat-last-read-at.ts | 13 + ...00000000-add-shop-notification-settings.ts | 23 + .../1784600000001-initialize-shop-settings.ts | 15 + .../1784700000000-add-favicon-storage-key.ts | 13 + backend/src/database/utils/drop.ts | 58 + backend/src/guards/JwtGuard.ts | 37 + backend/src/main.ts | 97 + .../middleware/shopSurfaceHeaderMiddleware.ts | 28 + backend/src/modules/auth/AuthModule.ts | 10 + .../auth/controllers/AuthController.ts | 34 + backend/src/modules/auth/dto/LoginDto.ts | 7 + .../modules/auth/services/AuthService.spec.ts | 47 + .../src/modules/auth/services/AuthService.ts | 31 + .../src/modules/dataWipe/DataWipeModule.ts | 11 + .../services/OrderDataWipeService.spec.ts | 181 + .../dataWipe/services/OrderDataWipeService.ts | 99 + .../modules/dataWipe/types/OrderWipeTarget.ts | 6 + .../discountCode/DiscountCodesModule.ts | 14 + .../controllers/DiscountCodesController.ts | 48 + .../dto/CreateOrUpdateDiscountCodeDto.ts | 56 + .../discountCode/entities/DiscountCode.ts | 92 + .../services/DiscountCodesService.spec.ts | 192 + .../services/DiscountCodesService.ts | 193 + .../discountCode/types/DiscountType.ts | 4 + .../modules/encryption/EncryptionModule.ts | 8 + .../services/EncryptionService.spec.ts | 113 + .../encryption/services/EncryptionService.ts | 137 + .../encryption/types/EncryptedField.ts | 5 + .../modules/healthCheck/HealthCheckModule.ts | 7 + .../controllers/HealthCheckController.ts | 12 + .../moneroWallet/MoneroWalletModule.ts | 14 + .../controllers/MoneroWalletController.ts | 30 + .../dto/MoneroWalletRevealSeedDto.ts | 7 + .../dto/MoneroWalletWithdrawDto.ts | 15 + .../services/MoneroWalletAdminService.spec.ts | 172 + .../services/MoneroWalletAdminService.ts | 135 + .../services/MoneroWalletRpcClient.spec.ts | 220 + .../services/MoneroWalletRpcClient.ts | 282 + .../MoneroWalletRpcConnectionService.spec.ts | 43 + .../MoneroWalletRpcConnectionService.ts | 20 + .../types/MoneroCreateAddressResult.ts | 6 + .../types/MoneroDaemonGetInfoResult.ts | 3 + .../types/MoneroWalletRevealSeedResult.ts | 3 + .../types/MoneroWalletRpcClientTest.ts | 7 + .../types/MoneroWalletRpcDigestChallenge.ts | 6 + .../types/MoneroWalletRpcError.ts | 4 + .../types/MoneroWalletRpcGetBalanceResult.ts | 4 + .../types/MoneroWalletRpcGetHeightResult.ts | 3 + .../MoneroWalletRpcGetTransfersResult.ts | 9 + .../types/MoneroWalletRpcGetVersionResult.ts | 4 + .../types/MoneroWalletRpcIncomingTransfer.ts | 6 + .../types/MoneroWalletRpcQueryKeyResult.ts | 3 + .../types/MoneroWalletRpcResponse.ts | 8 + .../types/MoneroWalletRpcSubaddrIndex.ts | 4 + .../types/MoneroWalletRpcSweepAllResult.ts | 4 + .../types/MoneroWalletRpcTransferEntry.ts | 14 + .../types/MoneroWalletStatusView.ts | 12 + .../types/MoneroWalletSyncStatus.ts | 5 + .../types/MoneroWalletWithdrawResult.ts | 4 + .../notifications/NotificationsModule.ts | 11 + .../services/NotificationService.spec.ts | 74 + .../services/NotificationService.ts | 42 + backend/src/modules/order/OrderModule.ts | 40 + .../order/controllers/OrderChatController.ts | 34 + .../order/controllers/OrdersController.ts | 31 + .../modules/order/dto/ListOrdersQueryDto.ts | 17 + .../modules/order/dto/SetDeliveryCostDto.ts | 8 + .../order/dto/SubmitOrderMessageDto.ts | 16 + backend/src/modules/order/entities/Order.ts | 67 + .../modules/order/entities/OrderDiscount.ts | 24 + .../src/modules/order/entities/OrderLine.ts | 59 + .../entities/OrderLineAutoFulfillmentItem.ts | 25 + .../OrderLineAutoFulfillmentItemAttachment.ts | 26 + .../entities/OrderLineManualFulfillment.ts | 19 + .../modules/order/entities/OrderMessage.ts | 22 + .../services/OrderAccessTokenService.spec.ts | 67 + .../order/services/OrderAccessTokenService.ts | 55 + .../order/services/OrderChatService.spec.ts | 201 + .../order/services/OrderChatService.ts | 90 + .../order/services/OrderClaimService.spec.ts | 541 + .../order/services/OrderClaimService.ts | 187 + .../services/OrderCreationService.spec.ts | 417 + .../order/services/OrderCreationService.ts | 161 + .../order/services/OrderService.spec.ts | 391 + .../modules/order/services/OrderService.ts | 299 + .../order/types/ClaimFromSessionResult.ts | 14 + .../order/types/GeneratedAccessToken.ts | 5 + .../types/ManualLineFulfillmentStatus.ts | 4 + .../order/types/OrderClaimServiceMocks.ts | 15 + .../src/modules/order/types/OrderExtended.ts | 12 + .../modules/order/types/OrderFailureReason.ts | 4 + .../src/modules/order/types/OrderListItem.ts | 18 + .../modules/order/types/OrderMessageSender.ts | 4 + .../src/modules/order/types/OrderStatus.ts | 5 + .../order/types/PreparedDiscountRedeem.ts | 4 + .../modules/order/types/PreparedStockClaim.ts | 14 + .../order/utils/isPreparedStockClaim.ts | 11 + backend/src/modules/payment/PaymentModule.ts | 20 + .../src/modules/payment/entities/Invoice.ts | 48 + .../payment/entities/InvoiceMoneroDetails.ts | 27 + .../payment/entities/InvoicePayment.ts | 25 + .../services/InvoicePaymentService.spec.ts | 368 + .../payment/services/InvoicePaymentService.ts | 130 + .../payment/services/InvoiceService.spec.ts | 217 + .../payment/services/InvoiceService.ts | 113 + .../modules/payment/types/InvoiceExtended.ts | 9 + .../payment/types/InvoicePaymentExtended.ts | 7 + .../types/InvoicePaymentServiceTest.ts | 6 + .../modules/payment/types/InvoiceReason.ts | 4 + .../payment/types/InvoiceReasonData.ts | 6 + .../modules/payment/types/IssueInvoiceData.ts | 9 + .../modules/payment/types/PaymentMethod.ts | 3 + backend/src/modules/product/ProductsModule.ts | 38 + .../config/digitalStockAttachmentUpload.ts | 10 + .../product/config/variantImageUpload.ts | 12 + .../controllers/CategoriesController.ts | 48 + .../controllers/ProductVariantsController.ts | 191 + .../product/controllers/ProductsController.ts | 52 + .../product/dto/AddDigitalStockItemDto.ts | 7 + .../product/dto/CreateOrUpdateCategoryDto.ts | 18 + .../dto/CreateOrUpdateProductVariantDto.ts | 29 + .../modules/product/dto/CreateProductDto.ts | 9 + .../dto/ListDigitalStockItemsQueryDto.ts | 22 + .../dto/ListProductVariantsQueryDto.ts | 21 + .../product/dto/ListProductsQueryDto.ts | 21 + .../product/dto/ReorderVariantImagesDto.ts | 8 + .../product/dto/UpdateDigitalStockItemDto.ts | 8 + .../modules/product/dto/UpdateProductDto.ts | 25 + .../src/modules/product/entities/Category.ts | 19 + .../product/entities/DigitalStockItem.ts | 35 + .../entities/DigitalStockItemAttachment.ts | 29 + .../src/modules/product/entities/Product.ts | 48 + .../product/entities/ProductVariant.ts | 52 + .../modules/product/entities/VariantImage.ts | 26 + .../services/CategoriesService.spec.ts | 48 + .../product/services/CategoriesService.ts | 93 + .../services/ProductVariantsService.spec.ts | 124 + .../services/ProductVariantsService.ts | 647 + .../product/services/ProductsService.spec.ts | 39 + .../product/services/ProductsService.ts | 196 + .../src/modules/product/types/DeliveryMode.ts | 4 + .../product/types/ProductVariantExtended.ts | 5 + .../types/ProductWithVariantsExtended.ts | 6 + .../shopSettings/ShopSettingsModule.ts | 14 + .../shopSettings/config/shopFaviconUpload.ts | 12 + .../shopSettings/config/shopLogoUpload.ts | 12 + .../controllers/ShopSettingsController.ts | 62 + .../dto/ConnectSimplexNotificationsDto.ts | 8 + .../dto/UpdateNotificationsDto.ts | 15 + .../shopSettings/dto/UpdateShippingNoteDto.ts | 15 + .../shopSettings/dto/UpdateSimplexLinkDto.ts | 8 + .../shopSettings/entities/ShopSettings.ts | 40 + .../services/ShopSettingsService.spec.ts | 81 + .../services/ShopSettingsService.ts | 248 + .../shopSettings/types/SetupChecklist.ts | 6 + .../types/ShopSettingsMoneroView.ts | 5 + .../shopSettings/types/ShopSettingsView.ts | 23 + .../shopSettings/types/StorefrontBranding.ts | 6 + backend/src/modules/simplex/SimplexModule.ts | 9 + .../simplex/services/SimplexChatClient.ts | 95 + .../simplex/services/SimplexChatWsService.ts | 302 + .../modules/simplex/types/ConnectOutcome.ts | 2 + .../modules/simplex/types/PendingCommand.ts | 7 + .../src/modules/simplex/types/PendingEvent.ts | 8 + .../simplex/types/SimplexAgentErrorType.ts | 9 + .../modules/simplex/types/SimplexChatError.ts | 29 + .../simplex/types/SimplexChatErrorTag.ts | 1 + .../simplex/types/SimplexChatEventTag.ts | 1 + .../simplex/types/SimplexChatResponse.ts | 6 + .../simplex/types/SimplexChatResponseData.ts | 11 + .../simplex/types/SimplexChatResponseTag.ts | 8 + .../simplex/types/SimplexConnectionPlan.ts | 6 + .../types/SimplexContactAddressPlan.ts | 6 + .../simplex/types/SimplexContactRef.ts | 3 + .../utils/formatSimplexConnectError.ts | 26 + .../utils/parseSimplexConnectResponse.ts | 34 + .../storefrontCart/StorefrontCartModule.ts | 18 + .../controllers/StorefrontCartController.ts | 178 + .../storefrontCart/dto/AddToCartDto.ts | 12 + .../dto/ApplyDiscountCodeDto.ts | 13 + .../storefrontCart/dto/CookieCartLineDto.ts | 13 + .../dto/RemoveDiscountCodeDto.ts | 13 + .../storefrontCart/dto/RemoveFromCartDto.ts | 7 + .../storefrontCart/dto/UpdateCartQtyDto.ts | 12 + .../StorefrontCartDiscountResolver.spec.ts | 283 + .../StorefrontCartDiscountResolver.ts | 299 + .../services/StorefrontCartService.spec.ts | 253 + .../services/StorefrontCartService.ts | 192 + .../StorefrontDiscountService.spec.ts | 119 + .../services/StorefrontDiscountService.ts | 54 + .../types/CartCookieMutation.ts | 3 + .../types/CartDiscountResolved.ts | 6 + .../modules/storefrontCart/types/CodeIssue.ts | 4 + .../types/ComputedCartDiscount.ts | 4 + .../types/CookieCartExtended.ts | 3 + .../types/CookieCartLineExtended.ts | 7 + .../storefrontCart/types/CookieCartSummary.ts | 13 + .../types/DiscountCookieMutation.ts | 1 + .../types/StorefrontCartDiscountState.ts | 7 + .../utils/getRedemptionLimitIssue.spec.ts | 19 + .../utils/getRedemptionLimitIssue.ts | 7 + .../utils/getStockIssue.spec.ts | 19 + .../storefrontCart/utils/getStockIssue.ts | 7 + .../utils/getZeroCartTotalIssue.spec.ts | 15 + .../utils/getZeroCartTotalIssue.ts | 7 + .../StorefrontCheckoutModule.ts | 27 + .../StorefrontCheckoutController.ts | 180 + .../storefrontCheckout/dto/PayCheckoutDto.ts | 15 + .../entities/CheckoutSession.ts | 42 + .../entities/CheckoutSessionDiscount.ts | 24 + .../entities/CheckoutSessionLine.ts | 51 + .../CheckoutPaymentPollerService.spec.ts | 145 + .../services/CheckoutPaymentPollerService.ts | 57 + .../services/CheckoutSessionService.spec.ts | 287 + .../services/CheckoutSessionService.ts | 102 + .../StorefrontCheckoutViewService.spec.ts | 131 + .../services/StorefrontCheckoutViewService.ts | 55 + .../types/CheckoutPaymentPollerServiceTest.ts | 3 + .../types/StorefrontCheckoutLineView.ts | 12 + .../types/StorefrontCheckoutView.ts | 13 + .../storefrontCore/StorefrontCoreModule.ts | 51 + .../controllers/StorefrontErrorController.ts | 29 + .../StorefrontPreferencesController.ts | 19 + .../dto/SetThemePreferenceDto.ts | 8 + .../filters/StorefrontExceptionFilter.ts | 93 + .../storefrontCore/guards/OrderAuthGuard.ts | 21 + .../storefrontCore/public/css/storefront.css | 1383 ++ .../StorefrontCaptchaCookieService.spec.ts | 30 + .../StorefrontCaptchaCookieService.ts | 21 + .../services/StorefrontCaptchaService.spec.ts | 59 + .../services/StorefrontCaptchaService.ts | 44 + .../StorefrontCartCookieService.spec.ts | 35 + .../services/StorefrontCartCookieService.ts | 23 + ...efrontCheckoutSessionCookieService.spec.ts | 29 + .../StorefrontCheckoutSessionCookieService.ts | 23 + .../StorefrontDiscountCookieService.spec.ts | 31 + .../StorefrontDiscountCookieService.ts | 26 + .../StorefrontErrorCookieService.spec.ts | 32 + .../services/StorefrontErrorCookieService.ts | 26 + .../StorefrontFeedbackCookieService.spec.ts | 33 + .../StorefrontFeedbackCookieService.ts | 19 + .../StorefrontOrderAuthCookieService.spec.ts | 109 + .../StorefrontOrderAuthCookieService.ts | 53 + .../StorefrontShopViewService.spec.ts | 312 + .../services/StorefrontShopViewService.ts | 132 + .../services/StorefrontSignedCookieService.ts | 130 + .../StorefrontThemeCookieService.spec.ts | 28 + .../services/StorefrontThemeCookieService.ts | 24 + .../types/AuthorizedOrderNavItem.ts | 6 + .../types/CheckoutSessionCookiePayload.ts | 3 + .../types/HandlebarsBlockOptions.ts | 4 + .../types/OrderAuthCookiePayload.ts | 3 + .../storefrontCore/types/ShopNavActive.ts | 6 + .../storefrontCore/types/ShopNavKey.ts | 1 + .../storefrontCore/types/ShopRenderLocals.ts | 23 + .../types/SignedCookieProfileKey.ts | 9 + .../types/StorefrontCaptchaCookiePayload.ts | 3 + .../types/StorefrontDiscountView.ts | 4 + .../types/StorefrontErrorPayload.ts | 4 + .../types/StorefrontFeedback.ts | 4 + .../types/StorefrontInvoicePaymentView.ts | 8 + .../types/StorefrontInvoiceView.ts | 29 + .../types/StorefrontPageMeta.ts | 9 + .../types/StorefrontPageMetaInput.ts | 9 + .../types/StorefrontProductJsonLdInput.ts | 5 + .../types/StorefrontThemePreference.ts | 1 + .../storefrontCore/types/cart/CookieCart.ts | 3 + .../types/cart/CookieCartLine.ts | 4 + .../utils/getHttpExceptionUserMessage.ts | 31 + .../utils/registerStorefrontHelpers.ts | 14 + .../utils/registerStorefrontPartials.ts | 46 + .../utils/resolveShopNavActive.spec.ts | 34 + .../utils/resolveShopNavActive.ts | 27 + .../storefrontCore/views/cart-summary.hbs | 18 + .../modules/storefrontCore/views/checkout.hbs | 47 + .../views/layouts/shop-minimal.hbs | 17 + .../storefrontCore/views/layouts/shop.hbs | 21 + .../storefrontCore/views/order-check.hbs | 15 + .../modules/storefrontCore/views/order.hbs | 71 + .../views/partials/auto-delivery-info.hbs | 9 + .../views/partials/cart-line-item.hbs | 47 + .../views/partials/cart-totals-panel.hbs | 96 + .../views/partials/category-nav.hbs | 8 + .../views/partials/checkout-line-item.hbs | 40 + .../views/partials/confirm-action-details.hbs | 9 + .../views/partials/dismiss-button.hbs | 6 + .../views/partials/feedback.hbs | 5 + .../views/partials/invoice-payment.hbs | 63 + .../views/partials/manual-shipping-info.hbs | 15 + .../views/partials/nav-link.hbs | 5 + .../views/partials/order-chat.hbs | 50 + .../partials/order-data-retention-notice.hbs | 6 + .../views/partials/order-digital-delivery.hbs | 19 + .../views/partials/order-line-item.hbs | 54 + .../partials/order-manual-fulfillment.hbs | 5 + .../views/partials/order-shipping-payment.hbs | 30 + .../views/partials/page-back-link.hbs | 1 + .../views/partials/product-card.hbs | 54 + .../views/partials/product-delivery-note.hbs | 6 + .../views/partials/qty-input.hbs | 13 + .../views/partials/refresh-link.hbs | 10 + .../views/partials/shop-favicon.hbs | 3 + .../views/partials/shop-footer.hbs | 8 + .../views/partials/shop-head.hbs | 20 + .../views/partials/shop-nav.hbs | 30 + .../views/partials/summary-grand-totals.hbs | 30 + .../views/partials/summary-totals-lines.hbs | 30 + .../views/partials/summary-totals.hbs | 8 + .../views/partials/theme-switcher.hbs | 19 + .../views/partials/variant-image-gallery.hbs | 25 + .../views/partials/variant-picker-link.hbs | 13 + .../storefrontCore/views/product-detail.hbs | 46 + .../storefrontCore/views/products-index.hbs | 11 + .../storefrontCore/views/shop-error.hbs | 3 + .../storefrontOrder/StorefrontOrderModule.ts | 14 + .../controllers/StorefrontOrderController.ts | 216 + .../services/StorefrontOrderService.spec.ts | 213 + .../services/StorefrontOrderService.ts | 110 + .../StorefrontOrderViewService.spec.ts | 387 + .../services/StorefrontOrderViewService.ts | 163 + .../types/FulfillmentAttachmentDownload.ts | 6 + .../types/StorefrontOrderChatView.ts | 6 + ...frontOrderDigitalDeliveryAttachmentView.ts | 4 + .../StorefrontOrderDigitalDeliveryView.ts | 6 + .../types/StorefrontOrderLineView.ts | 17 + .../StorefrontOrderManualFulfillmentView.ts | 5 + .../types/StorefrontOrderMessageView.ts | 6 + .../types/StorefrontOrderView.ts | 26 + .../StorefrontOrderViewServiceTestTypes.ts | 26 + .../buildStorefrontOrderRefreshHref.spec.ts | 10 + .../utils/buildStorefrontOrderRefreshHref.ts | 4 + ...esolveStorefrontOrderRefreshAnchor.spec.ts | 21 + .../resolveStorefrontOrderRefreshAnchor.ts | 12 + .../StorefrontProductModule.ts | 14 + .../StorefrontProductsController.ts | 121 + .../StorefrontProductViewService.spec.ts | 22 + .../services/StorefrontProductViewService.ts | 247 + .../StorefrontProductsService.spec.ts | 97 + .../services/StorefrontProductsService.ts | 151 + .../types/IndexVariantSelection.ts | 4 + .../types/ProductViewContext.ts | 8 + .../types/StorefrontCategoryNavItem.ts | 5 + .../types/StorefrontProduct.ts | 10 + .../types/StorefrontProductView.ts | 10 + .../types/StorefrontSelectedVariantView.ts | 8 + .../types/StorefrontVariant.ts | 15 + .../StorefrontVariantGalleryImageView.ts | 6 + .../types/StorefrontVariantImage.ts | 6 + .../types/StorefrontVariantView.ts | 7 + .../utils/resolveVariantThumbnailUrl.ts | 19 + backend/src/modules/xmrRate/XmrRateModule.ts | 8 + .../dto/CoingeckoSimplePriceResponseDto.ts | 39 + .../xmrRate/dto/KrakenTickerResponseDto.ts | 56 + backend/src/modules/xmrRate/krakenXmrPairs.ts | 10 + .../xmrRate/services/XmrRateService.spec.ts | 127 + .../xmrRate/services/XmrRateService.ts | 105 + backend/src/plugins/dayjs.ts | 6 + backend/src/types/Config.ts | 122 + .../src/types/DiskFileTypeValidatorOptions.ts | 3 + backend/src/types/MoneroConfirmationTier.ts | 4 + backend/src/types/MoneroNetwork.ts | 4 + backend/src/types/MoneroWalletConfig.ts | 10 + backend/src/types/NodeEnv.ts | 4 + backend/src/types/PaginatedResponse.ts | 6 + backend/src/types/ShopFiatCurrency.ts | 10 + backend/src/types/ShopSurface.ts | 4 + backend/src/types/SimplexConfig.ts | 4 + backend/src/types/UploadFileSource.ts | 1 + backend/src/types/ValidatedUploadFile.ts | 3 + .../database/InformationSchemaTableRow.ts | 3 + backend/src/types/database/PgEnumTypeRow.ts | 3 + .../StorefrontOrderRefreshSection.ts | 4 + .../src/types/validation/IsBase64Options.ts | 3 + .../src/types/validation/NullOrIntOptions.ts | 3 + .../types/validation/NullOrNumberOptions.ts | 3 + backend/src/utils/BufferFileTypeValidator.ts | 33 + backend/src/utils/ColumnBigIntTransformer.ts | 13 + backend/src/utils/ColumnNumericTransformer.ts | 15 + backend/src/utils/DiskFileTypeValidator.ts | 38 + backend/src/utils/atomic/addAtomic.spec.ts | 12 + backend/src/utils/atomic/addAtomic.ts | 5 + backend/src/utils/atomic/isAtomicGte.spec.ts | 19 + backend/src/utils/atomic/isAtomicGte.ts | 5 + .../src/utils/atomic/subtractAtomic.spec.ts | 12 + backend/src/utils/atomic/subtractAtomic.ts | 5 + .../src/utils/buildAllowedMimeRegex.spec.ts | 11 + backend/src/utils/buildAllowedMimeRegex.ts | 2 + .../cart/getQtyByVariantIdFromCart.spec.ts | 22 + .../utils/cart/getQtyByVariantIdFromCart.ts | 11 + .../cart/getTotalCartQtyFromCart.spec.ts | 16 + .../src/utils/cart/getTotalCartQtyFromCart.ts | 3 + .../deriveCheckoutSessionState.spec.ts | 109 + .../checkout/deriveCheckoutSessionState.ts | 18 + .../checkout/deriveCheckoutTotals.spec.ts | 67 + .../utils/checkout/deriveCheckoutTotals.ts | 15 + .../checkout/types/CheckoutSessionState.ts | 8 + .../types/CheckoutSessionStateInput.ts | 6 + .../utils/checkout/types/CheckoutTotals.ts | 5 + .../checkout/types/CheckoutTotalsInput.ts | 5 + .../utils/createDiskStorageUploadOptions.ts | 20 + backend/src/utils/createUploadFilePipe.ts | 26 + .../src/utils/formatRelativeTimeAgo.spec.ts | 11 + backend/src/utils/formatRelativeTimeAgo.ts | 5 + backend/src/utils/generateQrCodeDataUrl.ts | 5 + backend/src/utils/getErrorMessage.spec.ts | 12 + backend/src/utils/getErrorMessage.ts | 1 + .../getFileExtensionFromMimeType.spec.ts | 26 + .../src/utils/getFileExtensionFromMimeType.ts | 13 + .../deriveInvoiceConfirmationsMet.spec.ts | 37 + .../invoice/deriveInvoiceConfirmationsMet.ts | 13 + .../utils/invoice/deriveInvoiceState.spec.ts | 115 + .../src/utils/invoice/deriveInvoiceState.ts | 40 + ...atInvoicePaymentConfirmationStatus.spec.ts | 88 + .../formatInvoicePaymentConfirmationStatus.ts | 33 + ...esolveInvoiceRequiredConfirmations.spec.ts | 30 + .../resolveInvoiceRequiredConfirmations.ts | 18 + .../resolveInvoiceStatusMessage.spec.ts | 38 + .../invoice/resolveInvoiceStatusMessage.ts | 28 + .../resolveInvoiceStatusVariant.spec.ts | 42 + .../invoice/resolveInvoiceStatusVariant.ts | 28 + .../sumInvoicePaymentAmountsAtomic.spec.ts | 17 + .../invoice/sumInvoicePaymentAmountsAtomic.ts | 9 + .../invoice/toStorefrontInvoiceView.spec.ts | 664 + .../utils/invoice/toStorefrontInvoiceView.ts | 206 + .../types/InvoiceConfirmationsInput.ts | 7 + .../InvoicePaymentConfirmationStatusFormat.ts | 1 + .../InvoicePaymentConfirmationVariant.ts | 1 + .../src/utils/invoice/types/InvoiceState.ts | 9 + .../utils/invoice/types/InvoiceStateInput.ts | 9 + .../utils/invoice/types/InvoiceStatusLabel.ts | 6 + .../invoice/types/InvoiceStatusVariant.ts | 6 + backend/src/utils/isSet.spec.ts | 15 + backend/src/utils/isSet.ts | 1 + .../src/utils/monero/convertFiatToXmr.spec.ts | 11 + backend/src/utils/monero/convertFiatToXmr.ts | 5 + .../monero/convertXmrAtomicToXmr.spec.ts | 16 + .../src/utils/monero/convertXmrAtomicToXmr.ts | 9 + .../monero/convertXmrToXmrAtomic.spec.ts | 20 + .../src/utils/monero/convertXmrToXmrAtomic.ts | 6 + ...deduplicateIncomingMoneroTransfers.spec.ts | 16 + .../deduplicateIncomingMoneroTransfers.ts | 17 + ...omingMoneroTransfersBySubaddrIndex.spec.ts | 18 + ...upIncomingMoneroTransfersBySubaddrIndex.ts | 19 + .../monero/incomingMoneroTransfers.spec.ts | 76 + .../monero/resolveMinConfirmations.spec.ts | 21 + .../utils/monero/resolveMinConfirmations.ts | 16 + .../order/createOrderDetailQuery.spec.ts | 25 + .../src/utils/order/createOrderDetailQuery.ts | 24 + .../src/utils/order/deriveOrderState.spec.ts | 117 + backend/src/utils/order/deriveOrderState.ts | 45 + .../src/utils/order/deriveOrderTotals.spec.ts | 113 + backend/src/utils/order/deriveOrderTotals.ts | 23 + .../deriveShippingDeliveryCostFiat.spec.ts | 30 + .../order/deriveShippingDeliveryCostFiat.ts | 17 + .../utils/order/formatShortOrderId.spec.ts | 7 + backend/src/utils/order/formatShortOrderId.ts | 3 + .../utils/order/types/OrderLineStateInput.ts | 7 + backend/src/utils/order/types/OrderState.ts | 8 + .../src/utils/order/types/OrderStateInput.ts | 10 + backend/src/utils/order/types/OrderTotals.ts | 7 + .../src/utils/order/types/OrderTotalsInput.ts | 7 + .../order/types/ShippingDeliveryCostInput.ts | 4 + backend/src/utils/removeFileFromDisk.ts | 10 + .../safeInternalShopRedirectPath.spec.ts | 118 + .../src/utils/safeInternalShopRedirectPath.ts | 28 + .../src/utils/sanitizeUploadFilename.spec.ts | 17 + backend/src/utils/sanitizeUploadFilename.ts | 22 + .../src/utils/shouldUseSecureCookie.spec.ts | 24 + backend/src/utils/shouldUseSecureCookie.ts | 10 + backend/src/utils/sleep.ts | 4 + .../toStorefrontDiscountView.spec.ts | 10 + .../storefront/toStorefrontDiscountView.ts | 10 + .../types/StorefrontDiscountViewInput.ts | 4 + backend/src/utils/sumByKey.spec.ts | 19 + backend/src/utils/sumByKey.ts | 8 + backend/src/utils/toAbsoluteUrl.spec.ts | 20 + backend/src/utils/toAbsoluteUrl.ts | 11 + backend/src/utils/types/NumericKeyOf.ts | 3 + .../SafeInternalShopRedirectPathTestTypes.ts | 6 + backend/src/validation/decorators/isBase64.ts | 48 + .../isMoneroConfirmationTiers.spec.ts | 85 + .../decorators/isMoneroConfirmationTiers.ts | 93 + .../isMoneroStandardAddress.spec.ts | 184 + .../decorators/isMoneroStandardAddress.ts | 34 + backend/src/validation/decorators/nullOr.ts | 92 + backend/tsconfig.build.json | 4 + backend/tsconfig.json | 27 + cms/.dockerignore | 6 + cms/Dockerfile.dev | 11 + cms/index.html | 13 + cms/package-lock.json | 3649 +++++ cms/package.json | 37 + cms/public/favicon.svg | 1 + cms/src/App.vue | 88 + cms/src/components.d.ts | 75 + cms/src/components/CmsListPagination.vue | 68 + .../components/CreateOrEditCategoryModal.vue | 151 + .../CreateOrEditDiscountCodeModal.vue | 378 + .../components/CreateProductVariantModal.vue | 154 + cms/src/components/DiscountScopePicker.vue | 243 + cms/src/components/OrderCartPanel.vue | 206 + cms/src/components/OrderChatPanel.vue | 251 + .../OrderLineAutoFulfillmentModal.vue | 164 + .../OrderManualShippingQuotePanel.vue | 188 + .../components/OrderMoneroPaymentPanel.vue | 101 + cms/src/components/OrderPaymentPanel.vue | 25 + cms/src/components/OrderSummaryPanel.vue | 186 + cms/src/components/RichTextEditor.vue | 243 + cms/src/components/ThemeToggle.vue | 24 + .../VariantDetailDetailsCard.vue | 132 + .../VariantDetailDigitalStockSection.vue | 763 ++ .../variantDetail/VariantDetailImagesCard.vue | 345 + cms/src/components/wallet/MoneroWallet.vue | 312 + cms/src/composables/usePolling.ts | 63 + cms/src/config/index.ts | 51 + cms/src/consts/routeNames.ts | 13 + cms/src/consts/untitledProductTitle.ts | 1 + cms/src/main.ts | 11 + cms/src/plugins/axios.ts | 39 + cms/src/plugins/dayjs.ts | 6 + cms/src/router/index.ts | 106 + cms/src/stores/auth.ts | 31 + cms/src/stores/categories.ts | 57 + cms/src/stores/colorScheme.ts | 46 + cms/src/stores/digitalStock.ts | 148 + cms/src/stores/discountCodes.ts | 67 + cms/src/stores/moneroWallet.ts | 39 + cms/src/stores/orders.ts | 104 + cms/src/stores/products.ts | 335 + cms/src/stores/shopSettings.ts | 87 + cms/src/styles/_breakpoints.scss | 2 + cms/src/styles/responsive.scss | 124 + cms/src/styles/utils.scss | 158 + cms/src/types/BuildUploadHintOptions.ts | 7 + cms/src/types/PaginatedResponse.ts | 6 + cms/src/types/UploadValidationOptions.ts | 4 + cms/src/types/UsePollingOptions.ts | 7 + .../category/CreateOrUpdateCategoryPayload.ts | 4 + .../CreateOrUpdateDiscountCodePayload.ts | 16 + cms/src/types/discountCode/DiscountCode.ts | 23 + cms/src/types/discountCode/DiscountScope.ts | 6 + cms/src/types/discountCode/DiscountType.ts | 4 + cms/src/types/moneroWallet/MoneroNetwork.ts | 1 + .../MoneroWalletRevealSeedPayload.ts | 3 + .../MoneroWalletRevealSeedResult.ts | 3 + .../types/moneroWallet/MoneroWalletStatus.ts | 12 + .../moneroWallet/MoneroWalletSyncStatus.ts | 5 + .../MoneroWalletWithdrawPayload.ts | 4 + .../MoneroWalletWithdrawResult.ts | 4 + cms/src/types/order/InvoiceState.ts | 9 + .../order/ManualLineFulfillmentStatus.ts | 4 + cms/src/types/order/OrderDiscount.ts | 5 + cms/src/types/order/OrderExtended.ts | 25 + cms/src/types/order/OrderFailureReason.ts | 4 + cms/src/types/order/OrderLine.ts | 18 + .../order/OrderLineAutoFulfillmentItem.ts | 9 + .../OrderLineAutoFulfillmentItemAttachment.ts | 8 + .../types/order/OrderLineManualFulfillment.ts | 7 + cms/src/types/order/OrderListItem.ts | 18 + cms/src/types/order/OrderMessage.ts | 8 + cms/src/types/order/OrderMessageSender.ts | 4 + cms/src/types/order/OrderState.ts | 8 + cms/src/types/order/OrderStatus.ts | 5 + cms/src/types/order/OrderTotals.ts | 7 + cms/src/types/order/SetDeliveryCostPayload.ts | 3 + cms/src/types/payment/Invoice.ts | 18 + cms/src/types/payment/InvoiceExtended.ts | 9 + cms/src/types/payment/InvoiceMoneroDetails.ts | 6 + cms/src/types/payment/InvoicePayment.ts | 7 + .../types/payment/InvoicePaymentExtended.ts | 7 + cms/src/types/payment/InvoiceReason.ts | 4 + cms/src/types/payment/InvoiceStatusLabel.ts | 6 + cms/src/types/payment/PaymentMethod.ts | 7 + cms/src/types/product/Category.ts | 7 + cms/src/types/product/DeliveryMode.ts | 4 + .../types/product/DigitalStockAttachment.ts | 7 + cms/src/types/product/DigitalStockItem.ts | 10 + .../types/product/DigitalStockListQuery.ts | 5 + cms/src/types/product/PendingImageAction.ts | 4 + cms/src/types/product/Product.ts | 15 + cms/src/types/product/ProductOption.ts | 4 + cms/src/types/product/ProductVariant.ts | 16 + .../types/product/ProductVariantExtended.ts | 5 + .../types/product/ProductVariantPayload.ts | 6 + .../product/ProductWithVariantsExtended.ts | 6 + cms/src/types/product/UpdateProductPayload.ts | 6 + cms/src/types/product/VariantImage.ts | 8 + cms/src/types/product/VariantOption.ts | 4 + .../ConnectSimplexNotificationsPayload.ts | 3 + .../shopSettings/MoneroConfirmationTier.ts | 4 + cms/src/types/shopSettings/SetupChecklist.ts | 6 + cms/src/types/shopSettings/ShopSettings.ts | 22 + .../types/shopSettings/ShopSettingsMonero.ts | 5 + .../UpdateNotificationsPayload.ts | 5 + .../shopSettings/UpdateShippingNotePayload.ts | 3 + .../shopSettings/UpdateSimplexLinkPayload.ts | 3 + cms/src/utils/capitalizeFirstLetter.ts | 2 + cms/src/utils/formatDate.ts | 3 + cms/src/utils/formatFiatPrice.ts | 11 + cms/src/utils/formatFileSize.ts | 4 + cms/src/utils/formatRelativeTimeAgo.ts | 3 + cms/src/utils/getPaginationLastPage.ts | 2 + cms/src/utils/isSet.ts | 1 + .../utils/monero/isMoneroStandardAddress.ts | 19 + .../utils/order/formatOrderFailureReason.ts | 8 + .../order/resolveInvoiceStatusTagType.ts | 19 + .../utils/order/resolveOrderStatusTagType.ts | 14 + .../utils/product/compareProductVariants.ts | 4 + cms/src/utils/product/formatDeliveryMode.ts | 9 + cms/src/utils/product/getProductTitle.ts | 3 + cms/src/utils/product/getVariantLabel.ts | 4 + cms/src/utils/resolveAxiosErrorMessage.ts | 39 + cms/src/utils/upload/buildUploadHint.ts | 72 + .../utils/upload/resolveUploadPublicUrl.ts | 3 + cms/src/utils/upload/validateUpload.ts | 25 + cms/src/views/CmsCategoriesView.vue | 109 + cms/src/views/CmsDiscountCodesView.vue | 274 + cms/src/views/CmsLoginView.vue | 118 + cms/src/views/CmsNotificationsView.vue | 233 + cms/src/views/CmsOrderDetailView.vue | 122 + cms/src/views/CmsOrdersView.vue | 134 + cms/src/views/CmsProductDetailView.vue | 296 + cms/src/views/CmsProductVariantDetailView.vue | 133 + cms/src/views/CmsProductsView.vue | 176 + cms/src/views/CmsSettingsLayout.vue | 32 + cms/src/views/CmsShopSettingsView.vue | 488 + cms/src/views/CmsWalletView.vue | 7 + cms/src/vite-env.d.ts | 37 + cms/tsconfig.app.json | 16 + cms/tsconfig.json | 4 + cms/tsconfig.node.json | 24 + cms/vite.config.ts | 24 + deploy/DEPLOYMENT_GUIDE.md | 166 + deploy/scripts/bootstrap-certs.sh | 38 + deploy/scripts/deploy.sh | 17 + deploy/scripts/issue-certs.sh | 78 + deploy/scripts/renew-certs.sh | 25 + deploy/scripts/show-onion.sh | 15 + deploy/scripts/update.sh | 10 + deploy/tor/Dockerfile | 7 + deploy/tor/torrc | 5 + docker-compose.dev.yml | 121 + docker-compose.prod.yml | 151 + monero-wallet-rpc/Dockerfile | 38 + monero-wallet-rpc/setup-monero-wallet.sh | 222 + nginx/Dockerfile.prod | 64 + nginx/conf.d/clearnet.conf.template | 22 + nginx/conf.d/onion.conf.template | 6 + nginx/docker-entrypoint.sh | 31 + .../snippets/nullcart-locations.conf.template | 59 + simplex-cli/Dockerfile | 28 + simplex-cli/bot_avatar.jpeg | Bin 0 -> 1765 bytes simplex-cli/entrypoint.sh | 36 + 694 files changed, 49243 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 Readme.md create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile.dev create mode 100644 backend/Dockerfile.prod create mode 100644 backend/eslint.config.mjs create mode 100644 backend/nest-cli.json create mode 100644 backend/package-lock.json create mode 100644 backend/package.json create mode 100644 backend/src/AppModule.ts create mode 100644 backend/src/config/index.ts create mode 100644 backend/src/config/throttleProfiles.ts create mode 100644 backend/src/config/uploadPaths.ts create mode 100644 backend/src/config/validate.ts create mode 100644 backend/src/consts/shopSurfaceHeader.ts create mode 100644 backend/src/consts/storefrontOrderPageAnchor.ts create mode 100644 backend/src/consts/storefrontOrderRefreshSection.ts create mode 100644 backend/src/consts/xmrAtomicPerXmr.ts create mode 100644 backend/src/database/DataSource.ts create mode 100644 backend/src/database/migrations/1777811112018-init-products-crud.ts create mode 100644 backend/src/database/migrations/1778247115870-make-product-as-draft-initialy.ts create mode 100644 backend/src/database/migrations/1779658508834-add-physical-product-type.ts create mode 100644 backend/src/database/migrations/1779721786152-remove-price-unit-product-col.ts create mode 100644 backend/src/database/migrations/1780672489601-add-discount-code-entity.ts create mode 100644 backend/src/database/migrations/1781106764300-use-partial-unique-index-instead-of-unique-constraint-on-discount.ts create mode 100644 backend/src/database/migrations/1781195026200-add-many-to-many-discounts-to-products.ts create mode 100644 backend/src/database/migrations/1781785655067-add-product-categories.ts create mode 100644 backend/src/database/migrations/1781849627565-add-discount-to-category.ts create mode 100644 backend/src/database/migrations/1782151519819-add-product-variants-table.ts create mode 100644 backend/src/database/migrations/1782652984862-add-discount-code-variants-m2m.ts create mode 100644 backend/src/database/migrations/1782821226132-move-digital-stock-items-to-variant-scope.ts create mode 100644 backend/src/database/migrations/1782839159240-remove-default-variant.ts create mode 100644 backend/src/database/migrations/1783206651562-add-variant-images.ts create mode 100644 backend/src/database/migrations/1783522977172-product-type-rework.ts create mode 100644 backend/src/database/migrations/1783610487305-add-digital-stock-attachments.ts create mode 100644 backend/src/database/migrations/1783701820648-remove-soft-delete-cols.ts create mode 100644 backend/src/database/migrations/1783806278430-add-shop-settings.ts create mode 100644 backend/src/database/migrations/1783912100000-add-shipping-note.ts create mode 100644 backend/src/database/migrations/1784030873071-add-commerce-tables.ts create mode 100644 backend/src/database/migrations/1784500000000-add-order-staff-chat-last-read-at.ts create mode 100644 backend/src/database/migrations/1784600000000-add-shop-notification-settings.ts create mode 100644 backend/src/database/migrations/1784600000001-initialize-shop-settings.ts create mode 100644 backend/src/database/migrations/1784700000000-add-favicon-storage-key.ts create mode 100644 backend/src/database/utils/drop.ts create mode 100644 backend/src/guards/JwtGuard.ts create mode 100644 backend/src/main.ts create mode 100644 backend/src/middleware/shopSurfaceHeaderMiddleware.ts create mode 100644 backend/src/modules/auth/AuthModule.ts create mode 100644 backend/src/modules/auth/controllers/AuthController.ts create mode 100644 backend/src/modules/auth/dto/LoginDto.ts create mode 100644 backend/src/modules/auth/services/AuthService.spec.ts create mode 100644 backend/src/modules/auth/services/AuthService.ts create mode 100644 backend/src/modules/dataWipe/DataWipeModule.ts create mode 100644 backend/src/modules/dataWipe/services/OrderDataWipeService.spec.ts create mode 100644 backend/src/modules/dataWipe/services/OrderDataWipeService.ts create mode 100644 backend/src/modules/dataWipe/types/OrderWipeTarget.ts create mode 100644 backend/src/modules/discountCode/DiscountCodesModule.ts create mode 100644 backend/src/modules/discountCode/controllers/DiscountCodesController.ts create mode 100644 backend/src/modules/discountCode/dto/CreateOrUpdateDiscountCodeDto.ts create mode 100644 backend/src/modules/discountCode/entities/DiscountCode.ts create mode 100644 backend/src/modules/discountCode/services/DiscountCodesService.spec.ts create mode 100644 backend/src/modules/discountCode/services/DiscountCodesService.ts create mode 100644 backend/src/modules/discountCode/types/DiscountType.ts create mode 100644 backend/src/modules/encryption/EncryptionModule.ts create mode 100644 backend/src/modules/encryption/services/EncryptionService.spec.ts create mode 100644 backend/src/modules/encryption/services/EncryptionService.ts create mode 100644 backend/src/modules/encryption/types/EncryptedField.ts create mode 100644 backend/src/modules/healthCheck/HealthCheckModule.ts create mode 100644 backend/src/modules/healthCheck/controllers/HealthCheckController.ts create mode 100644 backend/src/modules/moneroWallet/MoneroWalletModule.ts create mode 100644 backend/src/modules/moneroWallet/controllers/MoneroWalletController.ts create mode 100644 backend/src/modules/moneroWallet/dto/MoneroWalletRevealSeedDto.ts create mode 100644 backend/src/modules/moneroWallet/dto/MoneroWalletWithdrawDto.ts create mode 100644 backend/src/modules/moneroWallet/services/MoneroWalletAdminService.spec.ts create mode 100644 backend/src/modules/moneroWallet/services/MoneroWalletAdminService.ts create mode 100644 backend/src/modules/moneroWallet/services/MoneroWalletRpcClient.spec.ts create mode 100644 backend/src/modules/moneroWallet/services/MoneroWalletRpcClient.ts create mode 100644 backend/src/modules/moneroWallet/services/MoneroWalletRpcConnectionService.spec.ts create mode 100644 backend/src/modules/moneroWallet/services/MoneroWalletRpcConnectionService.ts create mode 100644 backend/src/modules/moneroWallet/types/MoneroCreateAddressResult.ts create mode 100644 backend/src/modules/moneroWallet/types/MoneroDaemonGetInfoResult.ts create mode 100644 backend/src/modules/moneroWallet/types/MoneroWalletRevealSeedResult.ts create mode 100644 backend/src/modules/moneroWallet/types/MoneroWalletRpcClientTest.ts create mode 100644 backend/src/modules/moneroWallet/types/MoneroWalletRpcDigestChallenge.ts create mode 100644 backend/src/modules/moneroWallet/types/MoneroWalletRpcError.ts create mode 100644 backend/src/modules/moneroWallet/types/MoneroWalletRpcGetBalanceResult.ts create mode 100644 backend/src/modules/moneroWallet/types/MoneroWalletRpcGetHeightResult.ts create mode 100644 backend/src/modules/moneroWallet/types/MoneroWalletRpcGetTransfersResult.ts create mode 100644 backend/src/modules/moneroWallet/types/MoneroWalletRpcGetVersionResult.ts create mode 100644 backend/src/modules/moneroWallet/types/MoneroWalletRpcIncomingTransfer.ts create mode 100644 backend/src/modules/moneroWallet/types/MoneroWalletRpcQueryKeyResult.ts create mode 100644 backend/src/modules/moneroWallet/types/MoneroWalletRpcResponse.ts create mode 100644 backend/src/modules/moneroWallet/types/MoneroWalletRpcSubaddrIndex.ts create mode 100644 backend/src/modules/moneroWallet/types/MoneroWalletRpcSweepAllResult.ts create mode 100644 backend/src/modules/moneroWallet/types/MoneroWalletRpcTransferEntry.ts create mode 100644 backend/src/modules/moneroWallet/types/MoneroWalletStatusView.ts create mode 100644 backend/src/modules/moneroWallet/types/MoneroWalletSyncStatus.ts create mode 100644 backend/src/modules/moneroWallet/types/MoneroWalletWithdrawResult.ts create mode 100644 backend/src/modules/notifications/NotificationsModule.ts create mode 100644 backend/src/modules/notifications/services/NotificationService.spec.ts create mode 100644 backend/src/modules/notifications/services/NotificationService.ts create mode 100644 backend/src/modules/order/OrderModule.ts create mode 100644 backend/src/modules/order/controllers/OrderChatController.ts create mode 100644 backend/src/modules/order/controllers/OrdersController.ts create mode 100644 backend/src/modules/order/dto/ListOrdersQueryDto.ts create mode 100644 backend/src/modules/order/dto/SetDeliveryCostDto.ts create mode 100644 backend/src/modules/order/dto/SubmitOrderMessageDto.ts create mode 100644 backend/src/modules/order/entities/Order.ts create mode 100644 backend/src/modules/order/entities/OrderDiscount.ts create mode 100644 backend/src/modules/order/entities/OrderLine.ts create mode 100644 backend/src/modules/order/entities/OrderLineAutoFulfillmentItem.ts create mode 100644 backend/src/modules/order/entities/OrderLineAutoFulfillmentItemAttachment.ts create mode 100644 backend/src/modules/order/entities/OrderLineManualFulfillment.ts create mode 100644 backend/src/modules/order/entities/OrderMessage.ts create mode 100644 backend/src/modules/order/services/OrderAccessTokenService.spec.ts create mode 100644 backend/src/modules/order/services/OrderAccessTokenService.ts create mode 100644 backend/src/modules/order/services/OrderChatService.spec.ts create mode 100644 backend/src/modules/order/services/OrderChatService.ts create mode 100644 backend/src/modules/order/services/OrderClaimService.spec.ts create mode 100644 backend/src/modules/order/services/OrderClaimService.ts create mode 100644 backend/src/modules/order/services/OrderCreationService.spec.ts create mode 100644 backend/src/modules/order/services/OrderCreationService.ts create mode 100644 backend/src/modules/order/services/OrderService.spec.ts create mode 100644 backend/src/modules/order/services/OrderService.ts create mode 100644 backend/src/modules/order/types/ClaimFromSessionResult.ts create mode 100644 backend/src/modules/order/types/GeneratedAccessToken.ts create mode 100644 backend/src/modules/order/types/ManualLineFulfillmentStatus.ts create mode 100644 backend/src/modules/order/types/OrderClaimServiceMocks.ts create mode 100644 backend/src/modules/order/types/OrderExtended.ts create mode 100644 backend/src/modules/order/types/OrderFailureReason.ts create mode 100644 backend/src/modules/order/types/OrderListItem.ts create mode 100644 backend/src/modules/order/types/OrderMessageSender.ts create mode 100644 backend/src/modules/order/types/OrderStatus.ts create mode 100644 backend/src/modules/order/types/PreparedDiscountRedeem.ts create mode 100644 backend/src/modules/order/types/PreparedStockClaim.ts create mode 100644 backend/src/modules/order/utils/isPreparedStockClaim.ts create mode 100644 backend/src/modules/payment/PaymentModule.ts create mode 100644 backend/src/modules/payment/entities/Invoice.ts create mode 100644 backend/src/modules/payment/entities/InvoiceMoneroDetails.ts create mode 100644 backend/src/modules/payment/entities/InvoicePayment.ts create mode 100644 backend/src/modules/payment/services/InvoicePaymentService.spec.ts create mode 100644 backend/src/modules/payment/services/InvoicePaymentService.ts create mode 100644 backend/src/modules/payment/services/InvoiceService.spec.ts create mode 100644 backend/src/modules/payment/services/InvoiceService.ts create mode 100644 backend/src/modules/payment/types/InvoiceExtended.ts create mode 100644 backend/src/modules/payment/types/InvoicePaymentExtended.ts create mode 100644 backend/src/modules/payment/types/InvoicePaymentServiceTest.ts create mode 100644 backend/src/modules/payment/types/InvoiceReason.ts create mode 100644 backend/src/modules/payment/types/InvoiceReasonData.ts create mode 100644 backend/src/modules/payment/types/IssueInvoiceData.ts create mode 100644 backend/src/modules/payment/types/PaymentMethod.ts create mode 100644 backend/src/modules/product/ProductsModule.ts create mode 100644 backend/src/modules/product/config/digitalStockAttachmentUpload.ts create mode 100644 backend/src/modules/product/config/variantImageUpload.ts create mode 100644 backend/src/modules/product/controllers/CategoriesController.ts create mode 100644 backend/src/modules/product/controllers/ProductVariantsController.ts create mode 100644 backend/src/modules/product/controllers/ProductsController.ts create mode 100644 backend/src/modules/product/dto/AddDigitalStockItemDto.ts create mode 100644 backend/src/modules/product/dto/CreateOrUpdateCategoryDto.ts create mode 100644 backend/src/modules/product/dto/CreateOrUpdateProductVariantDto.ts create mode 100644 backend/src/modules/product/dto/CreateProductDto.ts create mode 100644 backend/src/modules/product/dto/ListDigitalStockItemsQueryDto.ts create mode 100644 backend/src/modules/product/dto/ListProductVariantsQueryDto.ts create mode 100644 backend/src/modules/product/dto/ListProductsQueryDto.ts create mode 100644 backend/src/modules/product/dto/ReorderVariantImagesDto.ts create mode 100644 backend/src/modules/product/dto/UpdateDigitalStockItemDto.ts create mode 100644 backend/src/modules/product/dto/UpdateProductDto.ts create mode 100644 backend/src/modules/product/entities/Category.ts create mode 100644 backend/src/modules/product/entities/DigitalStockItem.ts create mode 100644 backend/src/modules/product/entities/DigitalStockItemAttachment.ts create mode 100644 backend/src/modules/product/entities/Product.ts create mode 100644 backend/src/modules/product/entities/ProductVariant.ts create mode 100644 backend/src/modules/product/entities/VariantImage.ts create mode 100644 backend/src/modules/product/services/CategoriesService.spec.ts create mode 100644 backend/src/modules/product/services/CategoriesService.ts create mode 100644 backend/src/modules/product/services/ProductVariantsService.spec.ts create mode 100644 backend/src/modules/product/services/ProductVariantsService.ts create mode 100644 backend/src/modules/product/services/ProductsService.spec.ts create mode 100644 backend/src/modules/product/services/ProductsService.ts create mode 100644 backend/src/modules/product/types/DeliveryMode.ts create mode 100644 backend/src/modules/product/types/ProductVariantExtended.ts create mode 100644 backend/src/modules/product/types/ProductWithVariantsExtended.ts create mode 100644 backend/src/modules/shopSettings/ShopSettingsModule.ts create mode 100644 backend/src/modules/shopSettings/config/shopFaviconUpload.ts create mode 100644 backend/src/modules/shopSettings/config/shopLogoUpload.ts create mode 100644 backend/src/modules/shopSettings/controllers/ShopSettingsController.ts create mode 100644 backend/src/modules/shopSettings/dto/ConnectSimplexNotificationsDto.ts create mode 100644 backend/src/modules/shopSettings/dto/UpdateNotificationsDto.ts create mode 100644 backend/src/modules/shopSettings/dto/UpdateShippingNoteDto.ts create mode 100644 backend/src/modules/shopSettings/dto/UpdateSimplexLinkDto.ts create mode 100644 backend/src/modules/shopSettings/entities/ShopSettings.ts create mode 100644 backend/src/modules/shopSettings/services/ShopSettingsService.spec.ts create mode 100644 backend/src/modules/shopSettings/services/ShopSettingsService.ts create mode 100644 backend/src/modules/shopSettings/types/SetupChecklist.ts create mode 100644 backend/src/modules/shopSettings/types/ShopSettingsMoneroView.ts create mode 100644 backend/src/modules/shopSettings/types/ShopSettingsView.ts create mode 100644 backend/src/modules/shopSettings/types/StorefrontBranding.ts create mode 100644 backend/src/modules/simplex/SimplexModule.ts create mode 100644 backend/src/modules/simplex/services/SimplexChatClient.ts create mode 100644 backend/src/modules/simplex/services/SimplexChatWsService.ts create mode 100644 backend/src/modules/simplex/types/ConnectOutcome.ts create mode 100644 backend/src/modules/simplex/types/PendingCommand.ts create mode 100644 backend/src/modules/simplex/types/PendingEvent.ts create mode 100644 backend/src/modules/simplex/types/SimplexAgentErrorType.ts create mode 100644 backend/src/modules/simplex/types/SimplexChatError.ts create mode 100644 backend/src/modules/simplex/types/SimplexChatErrorTag.ts create mode 100644 backend/src/modules/simplex/types/SimplexChatEventTag.ts create mode 100644 backend/src/modules/simplex/types/SimplexChatResponse.ts create mode 100644 backend/src/modules/simplex/types/SimplexChatResponseData.ts create mode 100644 backend/src/modules/simplex/types/SimplexChatResponseTag.ts create mode 100644 backend/src/modules/simplex/types/SimplexConnectionPlan.ts create mode 100644 backend/src/modules/simplex/types/SimplexContactAddressPlan.ts create mode 100644 backend/src/modules/simplex/types/SimplexContactRef.ts create mode 100644 backend/src/modules/simplex/utils/formatSimplexConnectError.ts create mode 100644 backend/src/modules/simplex/utils/parseSimplexConnectResponse.ts create mode 100644 backend/src/modules/storefrontCart/StorefrontCartModule.ts create mode 100644 backend/src/modules/storefrontCart/controllers/StorefrontCartController.ts create mode 100644 backend/src/modules/storefrontCart/dto/AddToCartDto.ts create mode 100644 backend/src/modules/storefrontCart/dto/ApplyDiscountCodeDto.ts create mode 100644 backend/src/modules/storefrontCart/dto/CookieCartLineDto.ts create mode 100644 backend/src/modules/storefrontCart/dto/RemoveDiscountCodeDto.ts create mode 100644 backend/src/modules/storefrontCart/dto/RemoveFromCartDto.ts create mode 100644 backend/src/modules/storefrontCart/dto/UpdateCartQtyDto.ts create mode 100644 backend/src/modules/storefrontCart/services/StorefrontCartDiscountResolver.spec.ts create mode 100644 backend/src/modules/storefrontCart/services/StorefrontCartDiscountResolver.ts create mode 100644 backend/src/modules/storefrontCart/services/StorefrontCartService.spec.ts create mode 100644 backend/src/modules/storefrontCart/services/StorefrontCartService.ts create mode 100644 backend/src/modules/storefrontCart/services/StorefrontDiscountService.spec.ts create mode 100644 backend/src/modules/storefrontCart/services/StorefrontDiscountService.ts create mode 100644 backend/src/modules/storefrontCart/types/CartCookieMutation.ts create mode 100644 backend/src/modules/storefrontCart/types/CartDiscountResolved.ts create mode 100644 backend/src/modules/storefrontCart/types/CodeIssue.ts create mode 100644 backend/src/modules/storefrontCart/types/ComputedCartDiscount.ts create mode 100644 backend/src/modules/storefrontCart/types/CookieCartExtended.ts create mode 100644 backend/src/modules/storefrontCart/types/CookieCartLineExtended.ts create mode 100644 backend/src/modules/storefrontCart/types/CookieCartSummary.ts create mode 100644 backend/src/modules/storefrontCart/types/DiscountCookieMutation.ts create mode 100644 backend/src/modules/storefrontCart/types/StorefrontCartDiscountState.ts create mode 100644 backend/src/modules/storefrontCart/utils/getRedemptionLimitIssue.spec.ts create mode 100644 backend/src/modules/storefrontCart/utils/getRedemptionLimitIssue.ts create mode 100644 backend/src/modules/storefrontCart/utils/getStockIssue.spec.ts create mode 100644 backend/src/modules/storefrontCart/utils/getStockIssue.ts create mode 100644 backend/src/modules/storefrontCart/utils/getZeroCartTotalIssue.spec.ts create mode 100644 backend/src/modules/storefrontCart/utils/getZeroCartTotalIssue.ts create mode 100644 backend/src/modules/storefrontCheckout/StorefrontCheckoutModule.ts create mode 100644 backend/src/modules/storefrontCheckout/controllers/StorefrontCheckoutController.ts create mode 100644 backend/src/modules/storefrontCheckout/dto/PayCheckoutDto.ts create mode 100644 backend/src/modules/storefrontCheckout/entities/CheckoutSession.ts create mode 100644 backend/src/modules/storefrontCheckout/entities/CheckoutSessionDiscount.ts create mode 100644 backend/src/modules/storefrontCheckout/entities/CheckoutSessionLine.ts create mode 100644 backend/src/modules/storefrontCheckout/services/CheckoutPaymentPollerService.spec.ts create mode 100644 backend/src/modules/storefrontCheckout/services/CheckoutPaymentPollerService.ts create mode 100644 backend/src/modules/storefrontCheckout/services/CheckoutSessionService.spec.ts create mode 100644 backend/src/modules/storefrontCheckout/services/CheckoutSessionService.ts create mode 100644 backend/src/modules/storefrontCheckout/services/StorefrontCheckoutViewService.spec.ts create mode 100644 backend/src/modules/storefrontCheckout/services/StorefrontCheckoutViewService.ts create mode 100644 backend/src/modules/storefrontCheckout/types/CheckoutPaymentPollerServiceTest.ts create mode 100644 backend/src/modules/storefrontCheckout/types/StorefrontCheckoutLineView.ts create mode 100644 backend/src/modules/storefrontCheckout/types/StorefrontCheckoutView.ts create mode 100644 backend/src/modules/storefrontCore/StorefrontCoreModule.ts create mode 100644 backend/src/modules/storefrontCore/controllers/StorefrontErrorController.ts create mode 100644 backend/src/modules/storefrontCore/controllers/StorefrontPreferencesController.ts create mode 100644 backend/src/modules/storefrontCore/dto/SetThemePreferenceDto.ts create mode 100644 backend/src/modules/storefrontCore/filters/StorefrontExceptionFilter.ts create mode 100644 backend/src/modules/storefrontCore/guards/OrderAuthGuard.ts create mode 100644 backend/src/modules/storefrontCore/public/css/storefront.css create mode 100644 backend/src/modules/storefrontCore/services/StorefrontCaptchaCookieService.spec.ts create mode 100644 backend/src/modules/storefrontCore/services/StorefrontCaptchaCookieService.ts create mode 100644 backend/src/modules/storefrontCore/services/StorefrontCaptchaService.spec.ts create mode 100644 backend/src/modules/storefrontCore/services/StorefrontCaptchaService.ts create mode 100644 backend/src/modules/storefrontCore/services/StorefrontCartCookieService.spec.ts create mode 100644 backend/src/modules/storefrontCore/services/StorefrontCartCookieService.ts create mode 100644 backend/src/modules/storefrontCore/services/StorefrontCheckoutSessionCookieService.spec.ts create mode 100644 backend/src/modules/storefrontCore/services/StorefrontCheckoutSessionCookieService.ts create mode 100644 backend/src/modules/storefrontCore/services/StorefrontDiscountCookieService.spec.ts create mode 100644 backend/src/modules/storefrontCore/services/StorefrontDiscountCookieService.ts create mode 100644 backend/src/modules/storefrontCore/services/StorefrontErrorCookieService.spec.ts create mode 100644 backend/src/modules/storefrontCore/services/StorefrontErrorCookieService.ts create mode 100644 backend/src/modules/storefrontCore/services/StorefrontFeedbackCookieService.spec.ts create mode 100644 backend/src/modules/storefrontCore/services/StorefrontFeedbackCookieService.ts create mode 100644 backend/src/modules/storefrontCore/services/StorefrontOrderAuthCookieService.spec.ts create mode 100644 backend/src/modules/storefrontCore/services/StorefrontOrderAuthCookieService.ts create mode 100644 backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts create mode 100644 backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts create mode 100644 backend/src/modules/storefrontCore/services/StorefrontSignedCookieService.ts create mode 100644 backend/src/modules/storefrontCore/services/StorefrontThemeCookieService.spec.ts create mode 100644 backend/src/modules/storefrontCore/services/StorefrontThemeCookieService.ts create mode 100644 backend/src/modules/storefrontCore/types/AuthorizedOrderNavItem.ts create mode 100644 backend/src/modules/storefrontCore/types/CheckoutSessionCookiePayload.ts create mode 100644 backend/src/modules/storefrontCore/types/HandlebarsBlockOptions.ts create mode 100644 backend/src/modules/storefrontCore/types/OrderAuthCookiePayload.ts create mode 100644 backend/src/modules/storefrontCore/types/ShopNavActive.ts create mode 100644 backend/src/modules/storefrontCore/types/ShopNavKey.ts create mode 100644 backend/src/modules/storefrontCore/types/ShopRenderLocals.ts create mode 100644 backend/src/modules/storefrontCore/types/SignedCookieProfileKey.ts create mode 100644 backend/src/modules/storefrontCore/types/StorefrontCaptchaCookiePayload.ts create mode 100644 backend/src/modules/storefrontCore/types/StorefrontDiscountView.ts create mode 100644 backend/src/modules/storefrontCore/types/StorefrontErrorPayload.ts create mode 100644 backend/src/modules/storefrontCore/types/StorefrontFeedback.ts create mode 100644 backend/src/modules/storefrontCore/types/StorefrontInvoicePaymentView.ts create mode 100644 backend/src/modules/storefrontCore/types/StorefrontInvoiceView.ts create mode 100644 backend/src/modules/storefrontCore/types/StorefrontPageMeta.ts create mode 100644 backend/src/modules/storefrontCore/types/StorefrontPageMetaInput.ts create mode 100644 backend/src/modules/storefrontCore/types/StorefrontProductJsonLdInput.ts create mode 100644 backend/src/modules/storefrontCore/types/StorefrontThemePreference.ts create mode 100644 backend/src/modules/storefrontCore/types/cart/CookieCart.ts create mode 100644 backend/src/modules/storefrontCore/types/cart/CookieCartLine.ts create mode 100644 backend/src/modules/storefrontCore/utils/getHttpExceptionUserMessage.ts create mode 100644 backend/src/modules/storefrontCore/utils/registerStorefrontHelpers.ts create mode 100644 backend/src/modules/storefrontCore/utils/registerStorefrontPartials.ts create mode 100644 backend/src/modules/storefrontCore/utils/resolveShopNavActive.spec.ts create mode 100644 backend/src/modules/storefrontCore/utils/resolveShopNavActive.ts create mode 100644 backend/src/modules/storefrontCore/views/cart-summary.hbs create mode 100644 backend/src/modules/storefrontCore/views/checkout.hbs create mode 100644 backend/src/modules/storefrontCore/views/layouts/shop-minimal.hbs create mode 100644 backend/src/modules/storefrontCore/views/layouts/shop.hbs create mode 100644 backend/src/modules/storefrontCore/views/order-check.hbs create mode 100644 backend/src/modules/storefrontCore/views/order.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/auto-delivery-info.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/cart-line-item.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/cart-totals-panel.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/category-nav.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/checkout-line-item.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/confirm-action-details.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/dismiss-button.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/feedback.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/invoice-payment.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/manual-shipping-info.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/nav-link.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/order-chat.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/order-data-retention-notice.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/order-digital-delivery.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/order-line-item.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/order-manual-fulfillment.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/order-shipping-payment.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/page-back-link.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/product-card.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/product-delivery-note.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/qty-input.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/refresh-link.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/shop-favicon.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/shop-footer.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/shop-head.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/shop-nav.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/summary-grand-totals.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/summary-totals-lines.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/summary-totals.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/theme-switcher.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/variant-image-gallery.hbs create mode 100644 backend/src/modules/storefrontCore/views/partials/variant-picker-link.hbs create mode 100644 backend/src/modules/storefrontCore/views/product-detail.hbs create mode 100644 backend/src/modules/storefrontCore/views/products-index.hbs create mode 100644 backend/src/modules/storefrontCore/views/shop-error.hbs create mode 100644 backend/src/modules/storefrontOrder/StorefrontOrderModule.ts create mode 100644 backend/src/modules/storefrontOrder/controllers/StorefrontOrderController.ts create mode 100644 backend/src/modules/storefrontOrder/services/StorefrontOrderService.spec.ts create mode 100644 backend/src/modules/storefrontOrder/services/StorefrontOrderService.ts create mode 100644 backend/src/modules/storefrontOrder/services/StorefrontOrderViewService.spec.ts create mode 100644 backend/src/modules/storefrontOrder/services/StorefrontOrderViewService.ts create mode 100644 backend/src/modules/storefrontOrder/types/FulfillmentAttachmentDownload.ts create mode 100644 backend/src/modules/storefrontOrder/types/StorefrontOrderChatView.ts create mode 100644 backend/src/modules/storefrontOrder/types/StorefrontOrderDigitalDeliveryAttachmentView.ts create mode 100644 backend/src/modules/storefrontOrder/types/StorefrontOrderDigitalDeliveryView.ts create mode 100644 backend/src/modules/storefrontOrder/types/StorefrontOrderLineView.ts create mode 100644 backend/src/modules/storefrontOrder/types/StorefrontOrderManualFulfillmentView.ts create mode 100644 backend/src/modules/storefrontOrder/types/StorefrontOrderMessageView.ts create mode 100644 backend/src/modules/storefrontOrder/types/StorefrontOrderView.ts create mode 100644 backend/src/modules/storefrontOrder/types/StorefrontOrderViewServiceTestTypes.ts create mode 100644 backend/src/modules/storefrontOrder/utils/buildStorefrontOrderRefreshHref.spec.ts create mode 100644 backend/src/modules/storefrontOrder/utils/buildStorefrontOrderRefreshHref.ts create mode 100644 backend/src/modules/storefrontOrder/utils/resolveStorefrontOrderRefreshAnchor.spec.ts create mode 100644 backend/src/modules/storefrontOrder/utils/resolveStorefrontOrderRefreshAnchor.ts create mode 100644 backend/src/modules/storefrontProduct/StorefrontProductModule.ts create mode 100644 backend/src/modules/storefrontProduct/controllers/StorefrontProductsController.ts create mode 100644 backend/src/modules/storefrontProduct/services/StorefrontProductViewService.spec.ts create mode 100644 backend/src/modules/storefrontProduct/services/StorefrontProductViewService.ts create mode 100644 backend/src/modules/storefrontProduct/services/StorefrontProductsService.spec.ts create mode 100644 backend/src/modules/storefrontProduct/services/StorefrontProductsService.ts create mode 100644 backend/src/modules/storefrontProduct/types/IndexVariantSelection.ts create mode 100644 backend/src/modules/storefrontProduct/types/ProductViewContext.ts create mode 100644 backend/src/modules/storefrontProduct/types/StorefrontCategoryNavItem.ts create mode 100644 backend/src/modules/storefrontProduct/types/StorefrontProduct.ts create mode 100644 backend/src/modules/storefrontProduct/types/StorefrontProductView.ts create mode 100644 backend/src/modules/storefrontProduct/types/StorefrontSelectedVariantView.ts create mode 100644 backend/src/modules/storefrontProduct/types/StorefrontVariant.ts create mode 100644 backend/src/modules/storefrontProduct/types/StorefrontVariantGalleryImageView.ts create mode 100644 backend/src/modules/storefrontProduct/types/StorefrontVariantImage.ts create mode 100644 backend/src/modules/storefrontProduct/types/StorefrontVariantView.ts create mode 100644 backend/src/modules/storefrontProduct/utils/resolveVariantThumbnailUrl.ts create mode 100644 backend/src/modules/xmrRate/XmrRateModule.ts create mode 100644 backend/src/modules/xmrRate/dto/CoingeckoSimplePriceResponseDto.ts create mode 100644 backend/src/modules/xmrRate/dto/KrakenTickerResponseDto.ts create mode 100644 backend/src/modules/xmrRate/krakenXmrPairs.ts create mode 100644 backend/src/modules/xmrRate/services/XmrRateService.spec.ts create mode 100644 backend/src/modules/xmrRate/services/XmrRateService.ts create mode 100644 backend/src/plugins/dayjs.ts create mode 100644 backend/src/types/Config.ts create mode 100644 backend/src/types/DiskFileTypeValidatorOptions.ts create mode 100644 backend/src/types/MoneroConfirmationTier.ts create mode 100644 backend/src/types/MoneroNetwork.ts create mode 100644 backend/src/types/MoneroWalletConfig.ts create mode 100644 backend/src/types/NodeEnv.ts create mode 100644 backend/src/types/PaginatedResponse.ts create mode 100644 backend/src/types/ShopFiatCurrency.ts create mode 100644 backend/src/types/ShopSurface.ts create mode 100644 backend/src/types/SimplexConfig.ts create mode 100644 backend/src/types/UploadFileSource.ts create mode 100644 backend/src/types/ValidatedUploadFile.ts create mode 100644 backend/src/types/database/InformationSchemaTableRow.ts create mode 100644 backend/src/types/database/PgEnumTypeRow.ts create mode 100644 backend/src/types/storefront/StorefrontOrderRefreshSection.ts create mode 100644 backend/src/types/validation/IsBase64Options.ts create mode 100644 backend/src/types/validation/NullOrIntOptions.ts create mode 100644 backend/src/types/validation/NullOrNumberOptions.ts create mode 100644 backend/src/utils/BufferFileTypeValidator.ts create mode 100644 backend/src/utils/ColumnBigIntTransformer.ts create mode 100644 backend/src/utils/ColumnNumericTransformer.ts create mode 100644 backend/src/utils/DiskFileTypeValidator.ts create mode 100644 backend/src/utils/atomic/addAtomic.spec.ts create mode 100644 backend/src/utils/atomic/addAtomic.ts create mode 100644 backend/src/utils/atomic/isAtomicGte.spec.ts create mode 100644 backend/src/utils/atomic/isAtomicGte.ts create mode 100644 backend/src/utils/atomic/subtractAtomic.spec.ts create mode 100644 backend/src/utils/atomic/subtractAtomic.ts create mode 100644 backend/src/utils/buildAllowedMimeRegex.spec.ts create mode 100644 backend/src/utils/buildAllowedMimeRegex.ts create mode 100644 backend/src/utils/cart/getQtyByVariantIdFromCart.spec.ts create mode 100644 backend/src/utils/cart/getQtyByVariantIdFromCart.ts create mode 100644 backend/src/utils/cart/getTotalCartQtyFromCart.spec.ts create mode 100644 backend/src/utils/cart/getTotalCartQtyFromCart.ts create mode 100644 backend/src/utils/checkout/deriveCheckoutSessionState.spec.ts create mode 100644 backend/src/utils/checkout/deriveCheckoutSessionState.ts create mode 100644 backend/src/utils/checkout/deriveCheckoutTotals.spec.ts create mode 100644 backend/src/utils/checkout/deriveCheckoutTotals.ts create mode 100644 backend/src/utils/checkout/types/CheckoutSessionState.ts create mode 100644 backend/src/utils/checkout/types/CheckoutSessionStateInput.ts create mode 100644 backend/src/utils/checkout/types/CheckoutTotals.ts create mode 100644 backend/src/utils/checkout/types/CheckoutTotalsInput.ts create mode 100644 backend/src/utils/createDiskStorageUploadOptions.ts create mode 100644 backend/src/utils/createUploadFilePipe.ts create mode 100644 backend/src/utils/formatRelativeTimeAgo.spec.ts create mode 100644 backend/src/utils/formatRelativeTimeAgo.ts create mode 100644 backend/src/utils/generateQrCodeDataUrl.ts create mode 100644 backend/src/utils/getErrorMessage.spec.ts create mode 100644 backend/src/utils/getErrorMessage.ts create mode 100644 backend/src/utils/getFileExtensionFromMimeType.spec.ts create mode 100644 backend/src/utils/getFileExtensionFromMimeType.ts create mode 100644 backend/src/utils/invoice/deriveInvoiceConfirmationsMet.spec.ts create mode 100644 backend/src/utils/invoice/deriveInvoiceConfirmationsMet.ts create mode 100644 backend/src/utils/invoice/deriveInvoiceState.spec.ts create mode 100644 backend/src/utils/invoice/deriveInvoiceState.ts create mode 100644 backend/src/utils/invoice/formatInvoicePaymentConfirmationStatus.spec.ts create mode 100644 backend/src/utils/invoice/formatInvoicePaymentConfirmationStatus.ts create mode 100644 backend/src/utils/invoice/resolveInvoiceRequiredConfirmations.spec.ts create mode 100644 backend/src/utils/invoice/resolveInvoiceRequiredConfirmations.ts create mode 100644 backend/src/utils/invoice/resolveInvoiceStatusMessage.spec.ts create mode 100644 backend/src/utils/invoice/resolveInvoiceStatusMessage.ts create mode 100644 backend/src/utils/invoice/resolveInvoiceStatusVariant.spec.ts create mode 100644 backend/src/utils/invoice/resolveInvoiceStatusVariant.ts create mode 100644 backend/src/utils/invoice/sumInvoicePaymentAmountsAtomic.spec.ts create mode 100644 backend/src/utils/invoice/sumInvoicePaymentAmountsAtomic.ts create mode 100644 backend/src/utils/invoice/toStorefrontInvoiceView.spec.ts create mode 100644 backend/src/utils/invoice/toStorefrontInvoiceView.ts create mode 100644 backend/src/utils/invoice/types/InvoiceConfirmationsInput.ts create mode 100644 backend/src/utils/invoice/types/InvoicePaymentConfirmationStatusFormat.ts create mode 100644 backend/src/utils/invoice/types/InvoicePaymentConfirmationVariant.ts create mode 100644 backend/src/utils/invoice/types/InvoiceState.ts create mode 100644 backend/src/utils/invoice/types/InvoiceStateInput.ts create mode 100644 backend/src/utils/invoice/types/InvoiceStatusLabel.ts create mode 100644 backend/src/utils/invoice/types/InvoiceStatusVariant.ts create mode 100644 backend/src/utils/isSet.spec.ts create mode 100644 backend/src/utils/isSet.ts create mode 100644 backend/src/utils/monero/convertFiatToXmr.spec.ts create mode 100644 backend/src/utils/monero/convertFiatToXmr.ts create mode 100644 backend/src/utils/monero/convertXmrAtomicToXmr.spec.ts create mode 100644 backend/src/utils/monero/convertXmrAtomicToXmr.ts create mode 100644 backend/src/utils/monero/convertXmrToXmrAtomic.spec.ts create mode 100644 backend/src/utils/monero/convertXmrToXmrAtomic.ts create mode 100644 backend/src/utils/monero/deduplicateIncomingMoneroTransfers.spec.ts create mode 100644 backend/src/utils/monero/deduplicateIncomingMoneroTransfers.ts create mode 100644 backend/src/utils/monero/groupIncomingMoneroTransfersBySubaddrIndex.spec.ts create mode 100644 backend/src/utils/monero/groupIncomingMoneroTransfersBySubaddrIndex.ts create mode 100644 backend/src/utils/monero/incomingMoneroTransfers.spec.ts create mode 100644 backend/src/utils/monero/resolveMinConfirmations.spec.ts create mode 100644 backend/src/utils/monero/resolveMinConfirmations.ts create mode 100644 backend/src/utils/order/createOrderDetailQuery.spec.ts create mode 100644 backend/src/utils/order/createOrderDetailQuery.ts create mode 100644 backend/src/utils/order/deriveOrderState.spec.ts create mode 100644 backend/src/utils/order/deriveOrderState.ts create mode 100644 backend/src/utils/order/deriveOrderTotals.spec.ts create mode 100644 backend/src/utils/order/deriveOrderTotals.ts create mode 100644 backend/src/utils/order/deriveShippingDeliveryCostFiat.spec.ts create mode 100644 backend/src/utils/order/deriveShippingDeliveryCostFiat.ts create mode 100644 backend/src/utils/order/formatShortOrderId.spec.ts create mode 100644 backend/src/utils/order/formatShortOrderId.ts create mode 100644 backend/src/utils/order/types/OrderLineStateInput.ts create mode 100644 backend/src/utils/order/types/OrderState.ts create mode 100644 backend/src/utils/order/types/OrderStateInput.ts create mode 100644 backend/src/utils/order/types/OrderTotals.ts create mode 100644 backend/src/utils/order/types/OrderTotalsInput.ts create mode 100644 backend/src/utils/order/types/ShippingDeliveryCostInput.ts create mode 100644 backend/src/utils/removeFileFromDisk.ts create mode 100644 backend/src/utils/safeInternalShopRedirectPath.spec.ts create mode 100644 backend/src/utils/safeInternalShopRedirectPath.ts create mode 100644 backend/src/utils/sanitizeUploadFilename.spec.ts create mode 100644 backend/src/utils/sanitizeUploadFilename.ts create mode 100644 backend/src/utils/shouldUseSecureCookie.spec.ts create mode 100644 backend/src/utils/shouldUseSecureCookie.ts create mode 100644 backend/src/utils/sleep.ts create mode 100644 backend/src/utils/storefront/toStorefrontDiscountView.spec.ts create mode 100644 backend/src/utils/storefront/toStorefrontDiscountView.ts create mode 100644 backend/src/utils/storefront/types/StorefrontDiscountViewInput.ts create mode 100644 backend/src/utils/sumByKey.spec.ts create mode 100644 backend/src/utils/sumByKey.ts create mode 100644 backend/src/utils/toAbsoluteUrl.spec.ts create mode 100644 backend/src/utils/toAbsoluteUrl.ts create mode 100644 backend/src/utils/types/NumericKeyOf.ts create mode 100644 backend/src/utils/types/SafeInternalShopRedirectPathTestTypes.ts create mode 100644 backend/src/validation/decorators/isBase64.ts create mode 100644 backend/src/validation/decorators/isMoneroConfirmationTiers.spec.ts create mode 100644 backend/src/validation/decorators/isMoneroConfirmationTiers.ts create mode 100644 backend/src/validation/decorators/isMoneroStandardAddress.spec.ts create mode 100644 backend/src/validation/decorators/isMoneroStandardAddress.ts create mode 100644 backend/src/validation/decorators/nullOr.ts create mode 100644 backend/tsconfig.build.json create mode 100644 backend/tsconfig.json create mode 100644 cms/.dockerignore create mode 100644 cms/Dockerfile.dev create mode 100644 cms/index.html create mode 100644 cms/package-lock.json create mode 100644 cms/package.json create mode 100644 cms/public/favicon.svg create mode 100644 cms/src/App.vue create mode 100644 cms/src/components.d.ts create mode 100644 cms/src/components/CmsListPagination.vue create mode 100644 cms/src/components/CreateOrEditCategoryModal.vue create mode 100644 cms/src/components/CreateOrEditDiscountCodeModal.vue create mode 100644 cms/src/components/CreateProductVariantModal.vue create mode 100644 cms/src/components/DiscountScopePicker.vue create mode 100644 cms/src/components/OrderCartPanel.vue create mode 100644 cms/src/components/OrderChatPanel.vue create mode 100644 cms/src/components/OrderLineAutoFulfillmentModal.vue create mode 100644 cms/src/components/OrderManualShippingQuotePanel.vue create mode 100644 cms/src/components/OrderMoneroPaymentPanel.vue create mode 100644 cms/src/components/OrderPaymentPanel.vue create mode 100644 cms/src/components/OrderSummaryPanel.vue create mode 100644 cms/src/components/RichTextEditor.vue create mode 100644 cms/src/components/ThemeToggle.vue create mode 100644 cms/src/components/variantDetail/VariantDetailDetailsCard.vue create mode 100644 cms/src/components/variantDetail/VariantDetailDigitalStockSection.vue create mode 100644 cms/src/components/variantDetail/VariantDetailImagesCard.vue create mode 100644 cms/src/components/wallet/MoneroWallet.vue create mode 100644 cms/src/composables/usePolling.ts create mode 100644 cms/src/config/index.ts create mode 100644 cms/src/consts/routeNames.ts create mode 100644 cms/src/consts/untitledProductTitle.ts create mode 100644 cms/src/main.ts create mode 100644 cms/src/plugins/axios.ts create mode 100644 cms/src/plugins/dayjs.ts create mode 100644 cms/src/router/index.ts create mode 100644 cms/src/stores/auth.ts create mode 100644 cms/src/stores/categories.ts create mode 100644 cms/src/stores/colorScheme.ts create mode 100644 cms/src/stores/digitalStock.ts create mode 100644 cms/src/stores/discountCodes.ts create mode 100644 cms/src/stores/moneroWallet.ts create mode 100644 cms/src/stores/orders.ts create mode 100644 cms/src/stores/products.ts create mode 100644 cms/src/stores/shopSettings.ts create mode 100644 cms/src/styles/_breakpoints.scss create mode 100644 cms/src/styles/responsive.scss create mode 100644 cms/src/styles/utils.scss create mode 100644 cms/src/types/BuildUploadHintOptions.ts create mode 100644 cms/src/types/PaginatedResponse.ts create mode 100644 cms/src/types/UploadValidationOptions.ts create mode 100644 cms/src/types/UsePollingOptions.ts create mode 100644 cms/src/types/category/CreateOrUpdateCategoryPayload.ts create mode 100644 cms/src/types/discountCode/CreateOrUpdateDiscountCodePayload.ts create mode 100644 cms/src/types/discountCode/DiscountCode.ts create mode 100644 cms/src/types/discountCode/DiscountScope.ts create mode 100644 cms/src/types/discountCode/DiscountType.ts create mode 100644 cms/src/types/moneroWallet/MoneroNetwork.ts create mode 100644 cms/src/types/moneroWallet/MoneroWalletRevealSeedPayload.ts create mode 100644 cms/src/types/moneroWallet/MoneroWalletRevealSeedResult.ts create mode 100644 cms/src/types/moneroWallet/MoneroWalletStatus.ts create mode 100644 cms/src/types/moneroWallet/MoneroWalletSyncStatus.ts create mode 100644 cms/src/types/moneroWallet/MoneroWalletWithdrawPayload.ts create mode 100644 cms/src/types/moneroWallet/MoneroWalletWithdrawResult.ts create mode 100644 cms/src/types/order/InvoiceState.ts create mode 100644 cms/src/types/order/ManualLineFulfillmentStatus.ts create mode 100644 cms/src/types/order/OrderDiscount.ts create mode 100644 cms/src/types/order/OrderExtended.ts create mode 100644 cms/src/types/order/OrderFailureReason.ts create mode 100644 cms/src/types/order/OrderLine.ts create mode 100644 cms/src/types/order/OrderLineAutoFulfillmentItem.ts create mode 100644 cms/src/types/order/OrderLineAutoFulfillmentItemAttachment.ts create mode 100644 cms/src/types/order/OrderLineManualFulfillment.ts create mode 100644 cms/src/types/order/OrderListItem.ts create mode 100644 cms/src/types/order/OrderMessage.ts create mode 100644 cms/src/types/order/OrderMessageSender.ts create mode 100644 cms/src/types/order/OrderState.ts create mode 100644 cms/src/types/order/OrderStatus.ts create mode 100644 cms/src/types/order/OrderTotals.ts create mode 100644 cms/src/types/order/SetDeliveryCostPayload.ts create mode 100644 cms/src/types/payment/Invoice.ts create mode 100644 cms/src/types/payment/InvoiceExtended.ts create mode 100644 cms/src/types/payment/InvoiceMoneroDetails.ts create mode 100644 cms/src/types/payment/InvoicePayment.ts create mode 100644 cms/src/types/payment/InvoicePaymentExtended.ts create mode 100644 cms/src/types/payment/InvoiceReason.ts create mode 100644 cms/src/types/payment/InvoiceStatusLabel.ts create mode 100644 cms/src/types/payment/PaymentMethod.ts create mode 100644 cms/src/types/product/Category.ts create mode 100644 cms/src/types/product/DeliveryMode.ts create mode 100644 cms/src/types/product/DigitalStockAttachment.ts create mode 100644 cms/src/types/product/DigitalStockItem.ts create mode 100644 cms/src/types/product/DigitalStockListQuery.ts create mode 100644 cms/src/types/product/PendingImageAction.ts create mode 100644 cms/src/types/product/Product.ts create mode 100644 cms/src/types/product/ProductOption.ts create mode 100644 cms/src/types/product/ProductVariant.ts create mode 100644 cms/src/types/product/ProductVariantExtended.ts create mode 100644 cms/src/types/product/ProductVariantPayload.ts create mode 100644 cms/src/types/product/ProductWithVariantsExtended.ts create mode 100644 cms/src/types/product/UpdateProductPayload.ts create mode 100644 cms/src/types/product/VariantImage.ts create mode 100644 cms/src/types/product/VariantOption.ts create mode 100644 cms/src/types/shopSettings/ConnectSimplexNotificationsPayload.ts create mode 100644 cms/src/types/shopSettings/MoneroConfirmationTier.ts create mode 100644 cms/src/types/shopSettings/SetupChecklist.ts create mode 100644 cms/src/types/shopSettings/ShopSettings.ts create mode 100644 cms/src/types/shopSettings/ShopSettingsMonero.ts create mode 100644 cms/src/types/shopSettings/UpdateNotificationsPayload.ts create mode 100644 cms/src/types/shopSettings/UpdateShippingNotePayload.ts create mode 100644 cms/src/types/shopSettings/UpdateSimplexLinkPayload.ts create mode 100644 cms/src/utils/capitalizeFirstLetter.ts create mode 100644 cms/src/utils/formatDate.ts create mode 100644 cms/src/utils/formatFiatPrice.ts create mode 100644 cms/src/utils/formatFileSize.ts create mode 100644 cms/src/utils/formatRelativeTimeAgo.ts create mode 100644 cms/src/utils/getPaginationLastPage.ts create mode 100644 cms/src/utils/isSet.ts create mode 100644 cms/src/utils/monero/isMoneroStandardAddress.ts create mode 100644 cms/src/utils/order/formatOrderFailureReason.ts create mode 100644 cms/src/utils/order/resolveInvoiceStatusTagType.ts create mode 100644 cms/src/utils/order/resolveOrderStatusTagType.ts create mode 100644 cms/src/utils/product/compareProductVariants.ts create mode 100644 cms/src/utils/product/formatDeliveryMode.ts create mode 100644 cms/src/utils/product/getProductTitle.ts create mode 100644 cms/src/utils/product/getVariantLabel.ts create mode 100644 cms/src/utils/resolveAxiosErrorMessage.ts create mode 100644 cms/src/utils/upload/buildUploadHint.ts create mode 100644 cms/src/utils/upload/resolveUploadPublicUrl.ts create mode 100644 cms/src/utils/upload/validateUpload.ts create mode 100644 cms/src/views/CmsCategoriesView.vue create mode 100644 cms/src/views/CmsDiscountCodesView.vue create mode 100644 cms/src/views/CmsLoginView.vue create mode 100644 cms/src/views/CmsNotificationsView.vue create mode 100644 cms/src/views/CmsOrderDetailView.vue create mode 100644 cms/src/views/CmsOrdersView.vue create mode 100644 cms/src/views/CmsProductDetailView.vue create mode 100644 cms/src/views/CmsProductVariantDetailView.vue create mode 100644 cms/src/views/CmsProductsView.vue create mode 100644 cms/src/views/CmsSettingsLayout.vue create mode 100644 cms/src/views/CmsShopSettingsView.vue create mode 100644 cms/src/views/CmsWalletView.vue create mode 100644 cms/src/vite-env.d.ts create mode 100644 cms/tsconfig.app.json create mode 100644 cms/tsconfig.json create mode 100644 cms/tsconfig.node.json create mode 100644 cms/vite.config.ts create mode 100644 deploy/DEPLOYMENT_GUIDE.md create mode 100755 deploy/scripts/bootstrap-certs.sh create mode 100755 deploy/scripts/deploy.sh create mode 100755 deploy/scripts/issue-certs.sh create mode 100755 deploy/scripts/renew-certs.sh create mode 100755 deploy/scripts/show-onion.sh create mode 100755 deploy/scripts/update.sh create mode 100644 deploy/tor/Dockerfile create mode 100644 deploy/tor/torrc create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.prod.yml create mode 100644 monero-wallet-rpc/Dockerfile create mode 100755 monero-wallet-rpc/setup-monero-wallet.sh create mode 100644 nginx/Dockerfile.prod create mode 100644 nginx/conf.d/clearnet.conf.template create mode 100644 nginx/conf.d/onion.conf.template create mode 100755 nginx/docker-entrypoint.sh create mode 100644 nginx/snippets/nullcart-locations.conf.template create mode 100644 simplex-cli/Dockerfile create mode 100644 simplex-cli/bot_avatar.jpeg create mode 100755 simplex-cli/entrypoint.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3d85b87 --- /dev/null +++ b/.env.example @@ -0,0 +1,133 @@ +COMPOSE_PROJECT_NAME=nullcart_dev + +POSTGRES_HOST=postgres +POSTGRES_DB=nullcart +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_PORT=5432 +POSTGRES_MIGRATIONS_RUN=false + +PGADMIN_DEFAULT_EMAIL=admin@nullcart.net +PGADMIN_DEFAULT_PASSWORD=admin +PGADMIN_PORT=5555 + +BACKEND_PORT=3000 +CMS_PORT=5173 + +NODE_ENV=development + +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +CLEARNET_DOMAIN=localhost + +JWT_SECRET=change-me-in-production +JWT_EXPIRES_IN_MS=604800000 # 7 days + +CMS_PASSWORD=change-me + +SHOP_NAME="NullCart" +SHOP_FIAT_CURRENCY=USD + +CAPTCHA_LENGTH=5 + +THROTTLE_TTL_MS=60000 # 1 minute +THROTTLE_LIMIT=120 + +MULTER_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png +MULTER_PRODUCT_THUMB_MAX_FILE_BYTES=1048576 # 1MB + +MULTER_SHOP_LOGO_ALLOWED_MIMES=image/jpeg,image/png +MULTER_SHOP_LOGO_MAX_FILE_BYTES=1048576 # 1MB + +MULTER_SHOP_FAVICON_ALLOWED_MIMES=image/png,image/x-icon,image/vnd.microsoft.icon +MULTER_SHOP_FAVICON_MAX_FILE_BYTES=262144 # 256KB + +MULTER_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=application/pdf,application/zip,image/jpeg,image/png +MULTER_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=5242880 # 5MB + +VALIDATION_PRODUCT_TITLE_MAX_LENGTH=100 +VALIDATION_CATEGORY_NAME_MAX_LENGTH=50 +VALIDATION_DISCOUNT_CODE_MAX_LENGTH=32 +VALIDATION_VARIANT_IMAGES_MAX=10 +VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX=5 +VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20 +VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000 +VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000 + +SIGNED_COOKIE_JWT_SECRET=change-me-in-production + +SIGNED_COOKIE_FEEDBACK_NAME=storefront_feedback +SIGNED_COOKIE_FEEDBACK_EXPIRES_IN_MS=120000 # 120 seconds + +SIGNED_COOKIE_CART_NAME=storefront_cart +SIGNED_COOKIE_CART_EXPIRES_IN_MS=2592000000 # 30 days + +SIGNED_COOKIE_CAPTCHA_NAME=storefront_captcha +SIGNED_COOKIE_CAPTCHA_EXPIRES_IN_MS=300000 # 5 minutes + +SIGNED_COOKIE_DISCOUNT_NAME=storefront_discount +SIGNED_COOKIE_DISCOUNT_EXPIRES_IN_MS=2592000000 # 30 days + +SIGNED_COOKIE_ERROR_NAME=storefront_error +SIGNED_COOKIE_ERROR_EXPIRES_IN_MS=120000 # 120 seconds + +SIGNED_COOKIE_CHECKOUT_SESSION_NAME=storefront_checkout_session + +SIGNED_COOKIE_ORDER_AUTH_NAME=storefront_order_auth +SIGNED_COOKIE_ORDER_AUTH_EXPIRES_IN_MS=604800000 # 7 days + +SIGNED_COOKIE_THEME_NAME=storefront_theme +SIGNED_COOKIE_THEME_EXPIRES_IN_MS=31536000000 # 365 days + +COINGECKO_API_BASE_URL=https://api.coingecko.com/api/v3 +COINGECKO_XMR_RATE_FETCH_TIMEOUT_MS=5000 + +KRAKEN_API_BASE_URL=https://api.kraken.com/0/public +KRAKEN_XMR_RATE_FETCH_TIMEOUT_MS=5000 + +BASE64_ENCRYPTION_KEY="nyRya1KpYSQ+drpO132mkOEMUR+uq6K7tWvpMfppIME=" # Generate with: openssl rand -base64 32 + +MONERO_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":0},{"upToTotalFiat":"100","minConfirmations":3},{"upToTotalFiat":"300","minConfirmations":5},{"minConfirmations":10}]' +MONERO_VERSION=0.18.3.4 +MONERO_NETWORK=stagenet +MONERO_DAEMON_ADDRESS=xmr-lux.boldsuck.org:38081 +MONERO_WALLET_RPC_HOST=monero-wallet-rpc +MONERO_WALLET_RPC_PORT=18083 +MONERO_WALLET_RPC_USERNAME=monero +MONERO_WALLET_RPC_PASSWORD=monero +MONERO_WALLET_RPC_TIMEOUT_MS=10000 +MONERO_WALLET_DIR=./monero-wallet-rpc/wallet +MONERO_WALLET_NAME=shop +MONERO_WALLET_PASSWORD=change-me + +SIMPLEX_CHAT_VERSION=v6.5.6 +SIMPLEX_WS_URL=ws://simplex-cli:5225 +SIMPLEX_BOT_DISPLAY_NAME=NullCartBot +SIMPLEX_BOT_DESCRIPTION="I am your NullCart shop notifications bot" + +ORDER_CHECKOUT_VALIDITY_MS=3600000 # 1 hour +ORDER_CHECKOUT_STATUS_REFRESH_SEC=15 +ORDER_SHIPPING_PAYMENT_VALIDITY_MS=259200000 # 72 hours +ORDER_DATA_RETENTION_DAYS=30 + +MONERO_MIN_INCOMING_ATOMIC=10000000 # 0.00001 XMR + +VITE_API_BASE_URL=http://localhost:3000/api +VITE_SHOP_FIAT_CURRENCY=USD +VITE_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png +VITE_PRODUCT_THUMB_MAX_FILE_BYTES=1048576 # 1MB +VITE_SHOP_LOGO_ALLOWED_MIMES=image/jpeg,image/png +VITE_SHOP_LOGO_MAX_FILE_BYTES=1048576 # 1MB +VITE_SHOP_FAVICON_ALLOWED_MIMES=image/png,image/x-icon,image/vnd.microsoft.icon +VITE_SHOP_FAVICON_MAX_FILE_BYTES=262144 # 256KB +VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=application/pdf,application/zip,image/jpeg,image/png +VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=5242880 # 5MB +VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH=100 +VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH=50 +VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH=32 +VITE_VALIDATION_VARIANT_IMAGES_MAX=10 +VITE_VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX=5 +VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20 +VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000 +VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000 +VITE_ORDERS_DETAIL_POLL_INTERVAL_MS=5000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c6696df --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.env.dev +.env.prod + +/deploy/certs +/deploy/certbot/www + +/monero-wallet-rpc/wallet + +/backend/node_modules +/backend/dist +/backend/uploads + +/cms/node_modules +/cms/dist + +.vscode diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..edb3161 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,13 @@ +{ + "printWidth": 120, + "tabWidth": 4, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "none", + "bracketSpacing": true, + "proseWrap": "never", + "htmlWhitespaceSensitivity": "strict", + "endOfLine": "lf", + "arrowParens": "avoid" +} diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..a339846 --- /dev/null +++ b/Readme.md @@ -0,0 +1,65 @@ +# NullCart + +Self-hosted Monero shop with clearnet (HTTPS) and Tor onion hosting. + +For production deployment, see the [deployment guide](deploy/DEPLOYMENT_GUIDE.md). + +## Development + +### Start dev environment + +```bash +# Create and edit .env.dev as needed +cp .env.example .env.dev + +# Create monero wallet used by shop +./monero-wallet-rpc/setup-monero-wallet.sh --env-file .env.dev + +# Build and start docker containers +docker compose --env-file .env.dev -f docker-compose.dev.yml build --no-cache +docker compose --env-file .env.dev -f docker-compose.dev.yml up --force-recreate +``` + +### Install npm packages for code editor visibility + +```bash +cd backend && npm i && cd ../cms && npm i +``` + +### Database operations (run from inside container) + +```bash +docker exec -it /bin/sh +``` + +#### Running the migration + +```bash +npm run typeorm:run-migrations +``` + +#### Generating the migration + +```bash +npm run typeorm:generate-migration --name= +``` + +#### Reverting the last migration + +```bash +npm run typeorm:revert-migration +``` + +#### Seeders + +```bash +npm run typeorm:create-migration --name= +``` + +## Tests + +### Backend unit tests + +```bash +cd backend && npm run test +``` diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..d86bdda --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,8 @@ +node_modules +npm-debug.log +dist +.git +.gitignore +Dockerfile* +coverage +*.local diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev new file mode 100644 index 0000000..40195d9 --- /dev/null +++ b/backend/Dockerfile.dev @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /backend + +RUN apk add --no-cache curl + +COPY package*.json . + +RUN npm ci + +COPY . . + +CMD ["npm", "run", "start:dev"] diff --git a/backend/Dockerfile.prod b/backend/Dockerfile.prod new file mode 100644 index 0000000..e754e6e --- /dev/null +++ b/backend/Dockerfile.prod @@ -0,0 +1,15 @@ +FROM node:20-alpine + +WORKDIR /backend + +RUN apk add --no-cache curl + +COPY package*.json . + +RUN npm ci + +COPY . . + +RUN npm run build + +CMD ["node", "dist/main"] diff --git a/backend/eslint.config.mjs b/backend/eslint.config.mjs new file mode 100644 index 0000000..4384283 --- /dev/null +++ b/backend/eslint.config.mjs @@ -0,0 +1,44 @@ +// @ts-check +import eslint from '@eslint/js'; +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default [ + { + ignores: ['eslint.config.mjs'] + }, + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + eslintPluginPrettierRecommended, + { + languageOptions: { + globals: { + ...globals.node, + ...globals.jest + }, + sourceType: 'commonjs', + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname + } + } + }, + { + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-floating-promises': 'warn', + '@typescript-eslint/no-unsafe-argument': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + varsIgnorePattern: '^_', + ignoreRestSiblings: true + } + ], + 'prettier/prettier': ['error', { endOfLine: 'auto' }] + } + } +]; diff --git a/backend/nest-cli.json b/backend/nest-cli.json new file mode 100644 index 0000000..161ab93 --- /dev/null +++ b/backend/nest-cli.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true, + "assets": [ + "modules/storefrontCore/views/**/*.hbs", + "modules/storefrontCore/public/**/*" + ] + } +} diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..259e588 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,11388 @@ +{ + "name": "backend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "backend", + "version": "0.0.1", + "license": "UNLICENSED", + "dependencies": { + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.3", + "@nestjs/core": "^11.0.1", + "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^6.1.3", + "@nestjs/throttler": "^6.5.0", + "@nestjs/typeorm": "^11.0.1", + "axios": "^1.16.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "content-disposition": "^2.0.1", + "cookie-parser": "^1.4.7", + "dayjs": "^1.11.20", + "decimal.js": "^10.6.0", + "hbs": "^4.2.1", + "jsonwebtoken": "^9.0.3", + "multer": "^2.1.1", + "pg": "^8.20.0", + "qrcode": "^1.5.4", + "reflect-metadata": "^0.2.2", + "svg-captcha": "^1.4.0", + "typeorm": "^0.3.28", + "ws": "^8.21.3" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "12.0.0-alpha.6", + "@nestjs/schematics": "^11.0.0", + "@types/cookie-parser": "^1.4.10", + "@types/express": "^5.0.0", + "@types/hbs": "^4.0.5", + "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/multer": "^2.1.0", + "@types/node": "^22.10.7", + "@types/qrcode": "^1.5.6", + "@types/ws": "^8.18.1", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^16.0.0", + "jest": "^30.4.2", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "ts-jest": "^29.4.11", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.20.0" + } + }, + "node_modules/@angular-devkit/core": { + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.1.tgz", + "integrity": "sha512-TpXGjERqVPN8EPt7LdmWAwh0oNQ/6uWFutzGZiXhJy81n1zb1O1XrqhRAmvP1cAo5O+na6IV2JkkCmxL6F8GUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.3", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/core/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@angular-devkit/core/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-devkit/schematics": { + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.1.tgz", + "integrity": "sha512-CWoamHaasAHMjHcYqxbj0tMnoXxdGotcAz2SpiuWtH28Lnf5xfbTaJn/lwdMP8Wdh4tgA+uYh2l45A5auCwmkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "21.2.1", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.21", + "ora": "9.3.0", + "rxjs": "7.8.2" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli": { + "version": "21.2.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-21.2.1.tgz", + "integrity": "sha512-5uEyqfCfh5QCI0XfzWkxeR9IWFs06Qtxjpgx1EF5sLL0TpCOAVngU70DVCJMNQoNITdtDIYS2TxBXWXiFfILdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "21.2.1", + "@angular-devkit/schematics": "21.2.1", + "@inquirer/prompts": "7.10.1" + }, + "bin": { + "schematics": "bin/schematics.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.1.tgz", + "integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.2.2.tgz", + "integrity": "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/external-editor": "^3.0.3", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.1.tgz", + "integrity": "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.3.tgz", + "integrity": "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.2.tgz", + "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.1.1.tgz", + "integrity": "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.1.1.tgz", + "integrity": "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.3.0.tgz", + "integrity": "sha512-JAj66kjdH/F1+B7LCigjARbwstt3SNUOSzMdjpsvwJmzunK88gJeXmcm95L9nw1KynvFVuY4SzXh/3Y0lvtgSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.1.0", + "@inquirer/confirm": "^6.0.8", + "@inquirer/editor": "^5.0.8", + "@inquirer/expand": "^5.0.8", + "@inquirer/input": "^5.0.8", + "@inquirer/number": "^4.0.8", + "@inquirer/password": "^5.0.8", + "@inquirer/rawlist": "^5.2.4", + "@inquirer/search": "^4.1.4", + "@inquirer/select": "^5.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.1.tgz", + "integrity": "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.2.1.tgz", + "integrity": "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.1.tgz", + "integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", + "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", + "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/pattern": "30.4.0", + "@jest/reporters": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.4.1", + "jest-config": "30.4.2", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-resolve-dependencies": "30.4.2", + "jest-runner": "30.4.2", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "jest-watcher": "30.4.1", + "pretty-format": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.4.1", + "jest-snapshot": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", + "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/types": "30.4.1", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", + "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@jest/reporters/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", + "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", + "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/types": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", + "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", + "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@nestjs/cli": { + "version": "12.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-12.0.0-alpha.6.tgz", + "integrity": "sha512-OPfXw2mHXLz69m3YRiOhW4HKlUvxzYPlxk2YkT3OJvjvkSqIJLGJqh3XfdKLGxA6q2ZOut0R1Ubqsb5OqOEmBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "21.2.1", + "@angular-devkit/schematics": "21.2.1", + "@angular-devkit/schematics-cli": "21.2.1", + "@inquirer/prompts": "8.3.0", + "@nestjs/schematics": "next", + "ansis": "4.2.0", + "chokidar": "5.0.0", + "cli-table3": "0.6.5", + "commander": "14.0.3", + "glob": "13.0.6", + "node-emoji": "2.2.0", + "ora": "9.3.0", + "tsconfig-paths": "4.2.0", + "typescript": "~6.0.2" + }, + "bin": { + "nest": "bin/nest.js" + }, + "engines": { + "node": ">= 20.11" + }, + "peerDependencies": { + "@rspack/core": "^1.7.7", + "@swc/cli": "^0.8.0", + "@swc/core": "^1.15.18", + "fork-ts-checker-webpack-plugin": "^9.1.0", + "ts-loader": "^9.5.4", + "tsconfig-paths-webpack-plugin": "^4.2.0", + "webpack": "^5.105.4", + "webpack-node-externals": "^3.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "@swc/cli": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "fork-ts-checker-webpack-plugin": { + "optional": true + }, + "ts-loader": { + "optional": true + }, + "tsconfig-paths-webpack-plugin": { + "optional": true + }, + "webpack": { + "optional": true + }, + "webpack-node-externals": { + "optional": true + } + } + }, + "node_modules/@nestjs/common": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.2.1.tgz", + "integrity": "sha512-SEgtP+M9DqNhQkgJIlJ3oTp3gemo/8owySovzMGmJj2kcfIH1G6QP45AAb8dE4a3IpVicpUvdDAy7Syk7ebjBw==", + "license": "MIT", + "dependencies": { + "file-type": "21.3.4", + "iterare": "1.2.1", + "load-esm": "1.0.3", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": ">=0.4.1", + "class-validator": ">=0.13.2", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/config": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz", + "integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==", + "license": "MIT", + "dependencies": { + "dotenv": "17.4.1", + "dotenv-expand": "12.0.3", + "lodash": "4.18.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.2.1.tgz", + "integrity": "sha512-M5PWFU8NdRTgX9Po49d7TQKg7f5t8GAVUa/Esy4tmaMWcKugTzg3ZzpJfD3LEPMuRHjfs6+8pQYgtuP2uz3rDw==", + "license": "MIT", + "dependencies": { + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "engines": { + "node": ">= 20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nestjs/platform-express": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.2.1.tgz", + "integrity": "sha512-lbaVW94s1u8AJfgmBtdMPi16MEuFBiLrnflUIA9tZ9e5eoUsGTN7XXXRjU5kTTLgjnTCM53qgBXXGmTqmyfoQA==", + "license": "MIT", + "dependencies": { + "cors": "2.8.6", + "express": "5.2.1", + "multer": "2.2.0", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0" + } + }, + "node_modules/@nestjs/schedule": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.3.tgz", + "integrity": "sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==", + "license": "MIT", + "dependencies": { + "cron": "4.4.0" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, + "node_modules/@nestjs/schematics": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", + "integrity": "sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", + "comment-json": "5.0.0", + "jsonc-parser": "3.3.1", + "pluralize": "8.0.0" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "typescript": ">=4.8.2" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.24.tgz", + "integrity": "sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.24.tgz", + "integrity": "sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@nestjs/schematics/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@nestjs/schematics/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nestjs/schematics/node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@nestjs/schematics/node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@nestjs/schematics/node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@nestjs/schematics/node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@nestjs/schematics/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nestjs/schematics/node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@nestjs/schematics/node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/@nestjs/schematics/node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@nestjs/schematics/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@nestjs/schematics/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nestjs/schematics/node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@nestjs/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@nestjs/schematics/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@nestjs/schematics/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nestjs/throttler": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz", + "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, + "node_modules/@nestjs/typeorm": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.3.tgz", + "integrity": "sha512-zJ+E5l7auVVA7c0PsvcMdyvRPKTUqU5s2ToYmOA2QEsXQ42qbUGtK4+1HlRfpHqBkCSXP+phiH4luvf9DyJNog==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0", + "rxjs": "^7.2.0", + "typeorm": "^0.3.0 || ^1.0.0-dev" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sqltools/formatter": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", + "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", + "license": "MIT" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.13.tgz", + "integrity": "sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/hbs": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/hbs/-/hbs-4.0.5.tgz", + "integrity": "sha512-B08DCNOC1Dg9p/02Ny0wgvZOXQXe13wI02HvaA7p0BARqkNmQP380C6ZeNWuPRA9Qp7ek2p5nt46tRi7JqZQDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "handlebars": "^4.1.0" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/luxon": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.4.tgz", + "integrity": "sha512-V536ZAd6ZJztrrBlLcDFaaZrXNAL2E5uGmssWf/dpSiLkmkLScXUYhUBnWPmtW+cIqnNHzf6//TCMpIc9SCRRQ==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/app-root-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz", + "integrity": "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/babel-jest": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", + "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.4.1", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.4.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", + "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", + "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.4.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz", + "integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.1.tgz", + "integrity": "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz", + "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/comment-json": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-5.0.0.tgz", + "integrity": "sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/cron": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz", + "integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.7.0", + "luxon": "~3.7.0" + }, + "engines": { + "node": ">=18.x" + }, + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/intcreator" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.407", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz", + "integrity": "sha512-4R8XgQOdfxexCd/u63lRm6wCHjECwI45MV9wxAs2ggtfWe2hwlo1ql97jKsju2IcJ+jFSTwBssyYoiWhh7mauQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/emojilib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", + "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz", + "integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.13" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreachasync": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/foreachasync/-/foreachasync-3.0.0.tgz", + "integrity": "sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw==", + "license": "Apache2" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hbs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/hbs/-/hbs-4.2.1.tgz", + "integrity": "sha512-jkX8bTB17JCUMhyDobTkbMcdZi3xOplVbWjzp8i40O6ZITQhcjGIy11AmF51IyBe80GnJIRhUtKL9dg/ho/uog==", + "license": "MIT", + "dependencies": { + "handlebars": "4.7.9", + "walk": "2.3.15" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterare": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", + "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", + "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/types": "30.4.1", + "import-local": "^3.2.0", + "jest-cli": "30.4.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", + "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", + "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0", + "pretty-format": "30.4.1", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", + "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", + "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.4.0", + "@jest/test-sequencer": "30.4.1", + "@jest/types": "30.4.1", + "babel-jest": "30.4.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-circus": "30.4.2", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-runner": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "parse-json": "^5.2.0", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/jest-config/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jest-config/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-diff": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.4.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", + "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", + "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "jest-util": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", + "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", + "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "picomatch": "^4.0.3", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", + "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", + "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", + "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.4.0", + "jest-snapshot": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", + "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/environment": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-haste-map": "30.4.1", + "jest-leak-detector": "30.4.1", + "jest-message-util": "30.4.1", + "jest-resolve": "30.4.1", + "jest-runtime": "30.4.2", + "jest-util": "30.4.1", + "jest-watcher": "30.4.1", + "jest-worker": "30.4.1", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-runner/node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", + "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/globals": "30.4.1", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jest-runtime/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-snapshot": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", + "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-diff": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "pretty-format": "30.4.1", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", + "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.4.1", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.4.1", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.13.11", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.11.tgz", + "integrity": "sha512-ETER2kMaIFTI/Nh1a8Gk03dUF/SL0VZqtI+CcVHZxp5WIHYwNS7S+uiYZDYCvLy3lOR4/DAD5jf0h5WkePPpqg==", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-esm": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", + "integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "engines": { + "node": ">=13.2.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimizer-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/node-emoji": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", + "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.6.0", + "char-regex": "^1.0.2", + "emojilib": "^2.4.0", + "skin-tone": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/opentype.js": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-0.7.3.tgz", + "integrity": "sha512-Veui5vl2bLonFJ/SjX/WRWJT3SncgiZNnKUyahmXCc2sa1xXW15u3R/3TN5+JFiP7RsjK5ER4HA5eWaEmV9deA==", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.2" + }, + "bin": { + "ot": "bin/ot" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", + "integrity": "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.1", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/skin-tone": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", + "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-emoji-modifier-base": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/sql-highlight": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/sql-highlight/-/sql-highlight-6.1.0.tgz", + "integrity": "sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==", + "funding": [ + "https://github.com/scriptcoded/sql-highlight?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/scriptcoded" + } + ], + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stdin-discarder": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/svg-captcha": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/svg-captcha/-/svg-captcha-1.4.0.tgz", + "integrity": "sha512-/fkkhavXPE57zRRCjNqAP3txRCSncpMx3NnNZL7iEoyAtYwUjPhJxW6FQTQPG5UPEmCrbFoXS10C3YdJlW7PDg==", + "license": "MIT", + "dependencies": { + "opentype.js": "^0.7.3" + }, + "engines": { + "node": ">=4.x" + } + }, + "node_modules/synckit": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.3.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.50.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.50.0.tgz", + "integrity": "sha512-CN9BVxWhgS/hRxtUMjtC2uRWSTcSfQFHMDWma6sKKfIivCD91sM+FOPfvwoaRMqCSrUpe1nv3jDamd9eEQ4y+w==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-jest": { + "version": "29.4.12", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", + "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.8.5", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-loader": { + "version": "9.6.2", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.6.2.tgz", + "integrity": "sha512-R4iuczmtgxvtuI556s+hTZ6/7Ee03VCAk/l/M8LY1OAsUgB7YydsCxkgq9D9pKRaD7GJqUi2u8fp9zZP/ufjKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "picomatch": "^4.0.0", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "loader-utils": "*", + "typescript": "*", + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "loader-utils": { + "optional": true + } + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typeorm": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.31.tgz", + "integrity": "sha512-6u9EFtdLBgHjnPm78NStVeM+I/1MolTzKykDDcydzKUkh6E++YS6XViU/fePJbvDvEGU4Xq34KOM/CLeer9I2A==", + "license": "MIT", + "dependencies": { + "@sqltools/formatter": "^1.2.5", + "ansis": "^4.3.1", + "app-root-path": "^3.1.0", + "buffer": "^6.0.3", + "dayjs": "^1.11.21", + "debug": "^4.4.3", + "dedent": "^1.7.2", + "dotenv": "^16.6.1", + "glob": "^10.5.0", + "reflect-metadata": "^0.2.2", + "sha.js": "^2.4.12", + "sql-highlight": "^6.1.0", + "tslib": "^2.8.1", + "uuid": "^11.1.1", + "yargs": "^17.7.3" + }, + "bin": { + "typeorm": "cli.js", + "typeorm-ts-node-commonjs": "cli-ts-node-commonjs.js", + "typeorm-ts-node-esm": "cli-ts-node-esm.js" + }, + "engines": { + "node": ">=16.13.0" + }, + "funding": { + "url": "https://opencollective.com/typeorm" + }, + "peerDependencies": { + "@google-cloud/spanner": "^5.18.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@sap/hana-client": "^2.14.22", + "better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "ioredis": "^5.0.4", + "mongodb": "^5.8.0 || ^6.0.0", + "mssql": "^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "mysql2": "^2.2.5 || ^3.0.1", + "oracledb": "^6.3.0 || ^7.0.0", + "pg": "^8.5.1", + "pg-native": "^3.0.0", + "pg-query-stream": "^4.0.0", + "redis": "^3.1.1 || ^4.0.0 || ^5.0.14", + "sql.js": "^1.4.0", + "sqlite3": "^5.0.3 || ^6.0.0", + "ts-node": "^10.7.0", + "typeorm-aurora-data-api-driver": "^2.0.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@google-cloud/spanner": { + "optional": true + }, + "@sap/hana-client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mssql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-native": { + "optional": true + }, + "pg-query-stream": { + "optional": true + }, + "redis": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "ts-node": { + "optional": true + }, + "typeorm-aurora-data-api-driver": { + "optional": true + } + } + }, + "node_modules/typeorm/node_modules/ansis": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz", + "integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/typeorm/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/typeorm/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/typeorm/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typeorm/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/typeorm/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typeorm/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", + "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unicode-emoji-modifier-base": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", + "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/walk": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/walk/-/walk-2.3.15.tgz", + "integrity": "sha512-4eRTBZljBfIISK1Vnt69Gvr2w/wc3U6Vtrw7qiN5iqYJPH7LElcYh/iU4XWhdCy2dZqv1ToMyYlybDylfG/5Vg==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "foreachasync": "^3.0.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webpack": { + "version": "5.109.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.109.2.tgz", + "integrity": "sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.24.4", + "es-module-lexer": "^2.1.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "graceful-fs": "^4.2.11", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.6.1", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "watchpack": "^2.5.2", + "webpack-sources": "^3.5.1" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", + "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..2c3e3b0 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,79 @@ +{ + "name": "backend", + "version": "0.0.1", + "description": "", + "author": "nobswebdev", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "start": "nest start", + "start:dev": "nest start --watch --watchAssets", + "start:prod": "node dist/main", + "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli", + "typeorm:run-migrations": "npm run typeorm migration:run -- -d ./src/database/DataSource.ts", + "typeorm:generate-migration": "npm run typeorm -- -d ./src/database/DataSource.ts migration:generate ./src/database/migrations/$npm_config_name", + "typeorm:revert-migration": "npm run typeorm -- -d ./src/database/DataSource.ts migration:revert", + "typeorm:create-migration": "npm run typeorm -- migration:create ./src/database/migrations/$npm_config_name", + "truncate-db": "ts-node -r tsconfig-paths/register ./src/database/utils/drop", + "setup-fresh-db": "npm run truncate-db && npm run typeorm:run-migrations", + "test": "jest" + }, + "jest": { + "preset": "ts-jest", + "rootDir": "src" + }, + "dependencies": { + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.3", + "@nestjs/core": "^11.0.1", + "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^6.1.3", + "@nestjs/throttler": "^6.5.0", + "@nestjs/typeorm": "^11.0.1", + "axios": "^1.16.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "content-disposition": "^2.0.1", + "cookie-parser": "^1.4.7", + "dayjs": "^1.11.20", + "decimal.js": "^10.6.0", + "hbs": "^4.2.1", + "jsonwebtoken": "^9.0.3", + "multer": "^2.1.1", + "pg": "^8.20.0", + "qrcode": "^1.5.4", + "reflect-metadata": "^0.2.2", + "svg-captcha": "^1.4.0", + "typeorm": "^0.3.28", + "ws": "^8.21.3" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "12.0.0-alpha.6", + "@nestjs/schematics": "^11.0.0", + "@types/cookie-parser": "^1.4.10", + "@types/express": "^5.0.0", + "@types/hbs": "^4.0.5", + "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/multer": "^2.1.0", + "@types/node": "^22.10.7", + "@types/qrcode": "^1.5.6", + "@types/ws": "^8.18.1", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^16.0.0", + "jest": "^30.4.2", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "ts-jest": "^29.4.11", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.20.0" + } +} diff --git a/backend/src/AppModule.ts b/backend/src/AppModule.ts new file mode 100644 index 0000000..4914e9e --- /dev/null +++ b/backend/src/AppModule.ts @@ -0,0 +1,107 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService, registerAs } from '@nestjs/config'; +import { APP_GUARD } from '@nestjs/core'; +import { ScheduleModule } from '@nestjs/schedule'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { + getAppConfig, + getCoingeckoConfig, + getEncryptionConfig, + getJwtConfig, + getKrakenConfig, + getOrderConfig, + getInvoiceConfig, + getMoneroWalletConfig, + getPostgresConfig, + getShopSettingsConfig, + getSimplexConfig +} from './config'; +import { validate } from './config/validate'; +import { AuthModule } from './modules/auth/AuthModule'; +import { EncryptionModule } from './modules/encryption/EncryptionModule'; +import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule'; +import { MoneroWalletModule } from './modules/moneroWallet/MoneroWalletModule'; +import { SimplexModule } from './modules/simplex/SimplexModule'; +import { DiscountCodesModule } from './modules/discountCode/DiscountCodesModule'; +import { DataWipeModule } from './modules/dataWipe/DataWipeModule'; +import { NotificationsModule } from './modules/notifications/NotificationsModule'; +import { OrderModule } from './modules/order/OrderModule'; +import { PaymentModule } from './modules/payment/PaymentModule'; +import { ProductsModule } from './modules/product/ProductsModule'; +import { ShopSettingsModule } from './modules/shopSettings/ShopSettingsModule'; +import { StorefrontCartModule } from './modules/storefrontCart/StorefrontCartModule'; +import { StorefrontCheckoutModule } from './modules/storefrontCheckout/StorefrontCheckoutModule'; +import { StorefrontOrderModule } from './modules/storefrontOrder/StorefrontOrderModule'; +import { StorefrontCoreModule } from './modules/storefrontCore/StorefrontCoreModule'; +import { StorefrontProductModule } from './modules/storefrontProduct/StorefrontProductModule'; +import { XmrRateModule } from './modules/xmrRate/XmrRateModule'; +import { Config } from './types/Config'; + +@Module({ + imports: [ + ScheduleModule.forRoot(), + ConfigModule.forRoot({ + isGlobal: true, + validate, + load: [ + registerAs('postgres', getPostgresConfig), + registerAs('app', getAppConfig), + registerAs('jwt', getJwtConfig), + registerAs('coingecko', getCoingeckoConfig), + registerAs('kraken', getKrakenConfig), + registerAs('encryption', getEncryptionConfig), + registerAs('shopSettings', getShopSettingsConfig), + registerAs('order', getOrderConfig), + registerAs('invoice', getInvoiceConfig), + registerAs('moneroWallet', getMoneroWalletConfig), + registerAs('simplex', getSimplexConfig) + ] + }), + ThrottlerModule.forRootAsync({ + inject: [ConfigService], + useFactory: (configService: ConfigService) => { + const { throttle } = configService.get('app') as Config['app']; + + return { + throttlers: [ + { + name: 'default', + ttl: throttle.ttlMs, + limit: throttle.limit + } + ] + }; + } + }), + XmrRateModule, + TypeOrmModule.forRootAsync({ + useFactory: (configService: ConfigService) => configService.get('postgres') as Config['postgres'], + inject: [ConfigService] + }), + HealthCheckModule, + EncryptionModule, + AuthModule, + ProductsModule, + ShopSettingsModule, + SimplexModule, + NotificationsModule, + DiscountCodesModule, + OrderModule, + PaymentModule, + DataWipeModule, + MoneroWalletModule, + StorefrontCoreModule, + StorefrontProductModule, + StorefrontCartModule, + StorefrontCheckoutModule, + StorefrontOrderModule + ], + providers: [ + { + provide: APP_GUARD, + useClass: ThrottlerGuard + } + ] +}) +export class AppModule {} diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts new file mode 100644 index 0000000..56a5497 --- /dev/null +++ b/backend/src/config/index.ts @@ -0,0 +1,224 @@ +import { + AppConfig, + CoingeckoConfig, + EncryptionConfig, + InvoiceConfig, + JwtConfig, + KrakenConfig, + MulterConfig, + OrderConfig, + PostgresConfig, + ShopSettingsConfig +} from '../types/Config'; +import { MoneroNetwork } from '../types/MoneroNetwork'; +import { MoneroWalletConfig } from '../types/MoneroWalletConfig'; +import { MoneroConfirmationTier } from '../types/MoneroConfirmationTier'; +import { NodeEnv } from '../types/NodeEnv'; +import { ShopFiatCurrency } from '../types/ShopFiatCurrency'; +import { PaymentMethod } from '../modules/payment/types/PaymentMethod'; +import { SimplexConfig } from '../types/SimplexConfig'; + +const env = (key: string): string => process.env[key] || ''; + +const envInt = (key: string): number => parseInt(env(key), 10); + +const isEnabled = (key: string): boolean => env(key) === 'true'; + +export const getPostgresConfig = (): PostgresConfig => { + return { + type: 'postgres', + url: `postgres://${env('POSTGRES_USER')}:${env('POSTGRES_PASSWORD')}@${env('POSTGRES_HOST')}:${env('POSTGRES_PORT')}/${env('POSTGRES_DB')}`, + host: env('POSTGRES_HOST'), + username: env('POSTGRES_USER'), + password: env('POSTGRES_PASSWORD'), + port: envInt('POSTGRES_PORT'), + database: env('POSTGRES_DB'), + entities: [__dirname + '/../modules/**/entities/*{.ts,.js}'], + migrations: [__dirname + '/../database/migrations/*{.ts,.js}'], + migrationsRun: isEnabled('POSTGRES_MIGRATIONS_RUN') + }; +}; + +export const getAppConfig = (): AppConfig => { + const rawOrigins = env('CORS_ORIGINS'); + + return { + port: envInt('BACKEND_PORT'), + nodeEnv: env('NODE_ENV') as NodeEnv, + corsOrigins: rawOrigins + .split(',') + .map(o => o.trim()) + .filter(Boolean), + cmsPassword: env('CMS_PASSWORD'), + captcha: { + length: envInt('CAPTCHA_LENGTH') + }, + throttle: { + ttlMs: envInt('THROTTLE_TTL_MS'), + limit: envInt('THROTTLE_LIMIT') + }, + signedCookie: { + jwtSecret: env('SIGNED_COOKIE_JWT_SECRET'), + feedback: { + cookieName: env('SIGNED_COOKIE_FEEDBACK_NAME'), + expiresInMs: envInt('SIGNED_COOKIE_FEEDBACK_EXPIRES_IN_MS') + }, + cart: { + cookieName: env('SIGNED_COOKIE_CART_NAME'), + expiresInMs: envInt('SIGNED_COOKIE_CART_EXPIRES_IN_MS') + }, + captcha: { + cookieName: env('SIGNED_COOKIE_CAPTCHA_NAME'), + expiresInMs: envInt('SIGNED_COOKIE_CAPTCHA_EXPIRES_IN_MS') + }, + discount: { + cookieName: env('SIGNED_COOKIE_DISCOUNT_NAME'), + expiresInMs: envInt('SIGNED_COOKIE_DISCOUNT_EXPIRES_IN_MS') + }, + error: { + cookieName: env('SIGNED_COOKIE_ERROR_NAME'), + expiresInMs: envInt('SIGNED_COOKIE_ERROR_EXPIRES_IN_MS') + }, + checkoutSession: { + cookieName: env('SIGNED_COOKIE_CHECKOUT_SESSION_NAME'), + expiresInMs: envInt('ORDER_CHECKOUT_VALIDITY_MS') + }, + orderAuth: { + cookieName: env('SIGNED_COOKIE_ORDER_AUTH_NAME'), + expiresInMs: envInt('SIGNED_COOKIE_ORDER_AUTH_EXPIRES_IN_MS') + }, + theme: { + cookieName: env('SIGNED_COOKIE_THEME_NAME'), + expiresInMs: envInt('SIGNED_COOKIE_THEME_EXPIRES_IN_MS') + } + }, + validation: { + productTitleMaxLength: envInt('VALIDATION_PRODUCT_TITLE_MAX_LENGTH'), + categoryNameMaxLength: envInt('VALIDATION_CATEGORY_NAME_MAX_LENGTH'), + discountCodeMaxLength: envInt('VALIDATION_DISCOUNT_CODE_MAX_LENGTH'), + variantImagesMax: envInt('VALIDATION_VARIANT_IMAGES_MAX'), + digitalStockAttachmentsMax: envInt('VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX'), + shippingNoteMinLength: envInt('VALIDATION_SHIPPING_NOTE_MIN_LENGTH'), + shippingNoteMaxLength: envInt('VALIDATION_SHIPPING_NOTE_MAX_LENGTH'), + orderMessageMaxLength: envInt('VALIDATION_ORDER_MESSAGE_MAX_LENGTH') + } + }; +}; + +export const getJwtConfig = (): JwtConfig => { + return { + secret: env('JWT_SECRET'), + expiresInMs: envInt('JWT_EXPIRES_IN_MS') + }; +}; + +export const getProductThumbMulterConfig = (): MulterConfig => { + const allowedMimes = env('MULTER_PRODUCT_THUMB_ALLOWED_MIMES') + .split(',') + .map(v => v.trim()) + .filter(Boolean); + + const maxFileBytes = envInt('MULTER_PRODUCT_THUMB_MAX_FILE_BYTES'); + + return { allowedMimes, maxFileBytes }; +}; + +export const getShopLogoMulterConfig = (): MulterConfig => { + const allowedMimes = env('MULTER_SHOP_LOGO_ALLOWED_MIMES') + .split(',') + .map(v => v.trim()) + .filter(Boolean); + + const maxFileBytes = envInt('MULTER_SHOP_LOGO_MAX_FILE_BYTES'); + + return { allowedMimes, maxFileBytes }; +}; + +export const getShopFaviconMulterConfig = (): MulterConfig => { + const allowedMimes = env('MULTER_SHOP_FAVICON_ALLOWED_MIMES') + .split(',') + .map(v => v.trim()) + .filter(Boolean); + + const maxFileBytes = envInt('MULTER_SHOP_FAVICON_MAX_FILE_BYTES'); + + return { allowedMimes, maxFileBytes }; +}; + +export const getDigitalStockAttachmentMulterConfig = (): MulterConfig => { + const allowedMimes = env('MULTER_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES') + .split(',') + .map(v => v.trim()) + .filter(Boolean); + + const maxFileBytes = envInt('MULTER_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES'); + + return { allowedMimes, maxFileBytes }; +}; + +export const getCoingeckoConfig = (): CoingeckoConfig => { + return { + apiBaseUrl: env('COINGECKO_API_BASE_URL'), + xmrRateFetchTimeoutMs: envInt('COINGECKO_XMR_RATE_FETCH_TIMEOUT_MS') + }; +}; + +export const getKrakenConfig = (): KrakenConfig => { + return { + apiBaseUrl: env('KRAKEN_API_BASE_URL'), + xmrRateFetchTimeoutMs: envInt('KRAKEN_XMR_RATE_FETCH_TIMEOUT_MS') + }; +}; + +export const getEncryptionConfig = (): EncryptionConfig => ({ + keyBase64: env('BASE64_ENCRYPTION_KEY') +}); + +export const getShopSettingsConfig = (): ShopSettingsConfig => ({ + shopName: env('SHOP_NAME'), + shopFiatCurrency: env('SHOP_FIAT_CURRENCY') as ShopFiatCurrency, + monero: { + confirmationTiers: JSON.parse(env('MONERO_CONFIRMATION_TIERS')) as MoneroConfirmationTier[] + } +}); + +export const getOrderConfig = (): OrderConfig => ({ + checkoutValidityMs: envInt('ORDER_CHECKOUT_VALIDITY_MS'), + shippingPaymentValidityMs: envInt('ORDER_SHIPPING_PAYMENT_VALIDITY_MS'), + checkoutStatusRefreshSec: envInt('ORDER_CHECKOUT_STATUS_REFRESH_SEC'), + dataRetentionDays: envInt('ORDER_DATA_RETENTION_DAYS') +}); + +export const getInvoiceConfig = (): InvoiceConfig => ({ + minByMethod: { + [PaymentMethod.Xmr]: String(envInt('MONERO_MIN_INCOMING_ATOMIC')) + } +}); + +export const getMoneroWalletConfig = (): MoneroWalletConfig => ({ + network: env('MONERO_NETWORK') as MoneroNetwork, + rpcUrl: `http://${env('MONERO_WALLET_RPC_HOST')}:${envInt('MONERO_WALLET_RPC_PORT')}/json_rpc`, + daemonRpcUrl: `http://${env('MONERO_DAEMON_ADDRESS')}/json_rpc`, + username: env('MONERO_WALLET_RPC_USERNAME'), + password: env('MONERO_WALLET_RPC_PASSWORD'), + rpcTimeoutMs: envInt('MONERO_WALLET_RPC_TIMEOUT_MS') +}); + +export const getSimplexConfig = (): SimplexConfig => ({ + wsUrl: env('SIMPLEX_WS_URL'), + botDisplayName: env('SIMPLEX_BOT_DISPLAY_NAME') +}); + +export default () => ({ + postgres: getPostgresConfig(), + app: getAppConfig(), + jwt: getJwtConfig(), + coingecko: getCoingeckoConfig(), + kraken: getKrakenConfig(), + encryption: getEncryptionConfig(), + shopSettings: getShopSettingsConfig(), + order: getOrderConfig(), + invoice: getInvoiceConfig(), + moneroWallet: getMoneroWalletConfig(), + simplex: getSimplexConfig() +}); diff --git a/backend/src/config/throttleProfiles.ts b/backend/src/config/throttleProfiles.ts new file mode 100644 index 0000000..7550178 --- /dev/null +++ b/backend/src/config/throttleProfiles.ts @@ -0,0 +1,15 @@ +import { getAppConfig } from './index'; + +const { + throttle: { ttlMs } +} = getAppConfig(); + +export const throttleProfiles = { + checkOrder: { default: { limit: 10, ttl: ttlMs } }, + cmsLogin: { default: { limit: 5, ttl: ttlMs } }, + walletWithdraw: { default: { limit: 5, ttl: ttlMs } }, + walletRevealSeed: { default: { limit: 5, ttl: ttlMs } }, + checkoutPay: { default: { limit: 5, ttl: ttlMs } }, + cartDiscount: { default: { limit: 10, ttl: ttlMs } }, + cartPage: { default: { limit: 40, ttl: ttlMs } } +} as const; diff --git a/backend/src/config/uploadPaths.ts b/backend/src/config/uploadPaths.ts new file mode 100644 index 0000000..df70f11 --- /dev/null +++ b/backend/src/config/uploadPaths.ts @@ -0,0 +1,56 @@ +import { join, resolve, sep } from 'node:path'; + +const uploadsRoot = (): string => join(process.cwd(), 'uploads'); + +const validatePathTraversal = (baseDir: string, absolutePath: string): void => { + const resolvedBaseDir = resolve(baseDir); + + if (!absolutePath.startsWith(`${resolvedBaseDir}${sep}`)) { + throw new Error('Invalid storage key'); + } +}; + +const resolvePathWithinDir = (baseDir: string, storageKey: string): string => { + const resolvedBaseDir = resolve(baseDir); + const absolutePath = resolve(resolvedBaseDir, storageKey); + + validatePathTraversal(resolvedBaseDir, absolutePath); + + return absolutePath; +}; + +export const getPublicUploadsDir = (): string => join(uploadsRoot(), 'public'); + +export const publicUploadsUrlPrefix = '/uploads/public/'; + +export const getPrivateUploadsDir = (): string => join(uploadsRoot(), 'private'); + +export const getVariantImagesDir = (): string => join(getPublicUploadsDir(), 'variants', 'images'); + +export const variantImagesUrlPrefix = '/uploads/public/variants/images/'; + +export const getVariantImagePublicUrl = (filename: string): string => `${variantImagesUrlPrefix}${filename}`; + +export const getShopBrandingDir = (): string => join(getPublicUploadsDir(), 'shop'); + +export const shopBrandingUrlPrefix = '/uploads/public/shop/'; + +export const getShopBrandingPublicUrl = (filename: string): string => `${shopBrandingUrlPrefix}${filename}`; + +export const resolveShopBrandingPath = (storageKey: string): string => { + const baseDir = getShopBrandingDir(); + + const absolutePath = resolvePathWithinDir(baseDir, storageKey); + + return absolutePath; +}; + +export const getDigitalStockAttachmentsDir = (): string => join(getPrivateUploadsDir(), 'digital-stock', 'attachments'); + +export const resolveDigitalStockAttachmentPath = (storageKey: string): string => { + const baseDir = getDigitalStockAttachmentsDir(); + + const absolutePath = resolvePathWithinDir(baseDir, storageKey); + + return absolutePath; +}; diff --git a/backend/src/config/validate.ts b/backend/src/config/validate.ts new file mode 100644 index 0000000..6a4d07b --- /dev/null +++ b/backend/src/config/validate.ts @@ -0,0 +1,351 @@ +import { plainToClass } from 'class-transformer'; +import { IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsString, Max, Min, validateSync } from 'class-validator'; +import { NodeEnv } from '../types/NodeEnv'; +import { ShopFiatCurrency } from '../types/ShopFiatCurrency'; +import { IsBase64 } from '../validation/decorators/isBase64'; +import { IsMoneroConfirmationTiers } from '../validation/decorators/isMoneroConfirmationTiers'; +import { MoneroNetwork } from '../types/MoneroNetwork'; + +class EnvironmentVariables { + @IsNotEmpty() + @IsEnum(NodeEnv) + NODE_ENV: NodeEnv; + + @IsNotEmpty() + @IsNumber() + BACKEND_PORT: number; + + @IsNotEmpty() + @IsString() + POSTGRES_USER: string; + + @IsNotEmpty() + @IsString() + POSTGRES_PASSWORD: string; + + @IsNotEmpty() + @IsString() + POSTGRES_DB: string; + + @IsNotEmpty() + @IsString() + POSTGRES_HOST: string; + + @IsNotEmpty() + @IsNumber() + POSTGRES_PORT: number; + + @IsNotEmpty() + @IsBoolean() + POSTGRES_MIGRATIONS_RUN: boolean; + + @IsNotEmpty() + @IsString() + CORS_ORIGINS: string; + + @IsNotEmpty() + @IsString() + JWT_SECRET: string; + + @IsNotEmpty() + @IsNumber() + JWT_EXPIRES_IN_MS: number; + + @IsNotEmpty() + @IsString() + CMS_PASSWORD: string; + + @IsNotEmpty() + @IsString() + SHOP_NAME: string; + + @IsNotEmpty() + @IsEnum(ShopFiatCurrency) + SHOP_FIAT_CURRENCY: ShopFiatCurrency; + + @IsNotEmpty() + @IsString() + MULTER_PRODUCT_THUMB_ALLOWED_MIMES: string; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(10 * 1024 * 1024) // 10MB + MULTER_PRODUCT_THUMB_MAX_FILE_BYTES: number; + + @IsNotEmpty() + @IsString() + MULTER_SHOP_LOGO_ALLOWED_MIMES: string; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(10 * 1024 * 1024) // 10MB + MULTER_SHOP_LOGO_MAX_FILE_BYTES: number; + + @IsNotEmpty() + @IsString() + MULTER_SHOP_FAVICON_ALLOWED_MIMES: string; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(10 * 1024 * 1024) // 10MB + MULTER_SHOP_FAVICON_MAX_FILE_BYTES: number; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(256) + VALIDATION_PRODUCT_TITLE_MAX_LENGTH: number; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(256) + VALIDATION_CATEGORY_NAME_MAX_LENGTH: number; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(64) + VALIDATION_DISCOUNT_CODE_MAX_LENGTH: number; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(100) + VALIDATION_VARIANT_IMAGES_MAX: number; + + @IsNotEmpty() + @IsString() + MULTER_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES: string; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(50 * 1024 * 1024) // 50MB + MULTER_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES: number; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(20) + VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX: number; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(1000) + VALIDATION_SHIPPING_NOTE_MIN_LENGTH: number; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(10000) + VALIDATION_SHIPPING_NOTE_MAX_LENGTH: number; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(10000) + VALIDATION_ORDER_MESSAGE_MAX_LENGTH: number; + + @IsNotEmpty() + @IsNumber() + @Min(4) + @Max(8) + CAPTCHA_LENGTH: number; + + @IsNotEmpty() + @IsNumber() + @Min(1000) + @Max(3_600_000) + THROTTLE_TTL_MS: number; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(10_000) + THROTTLE_LIMIT: number; + + @IsNotEmpty() + @IsString() + SIGNED_COOKIE_JWT_SECRET: string; + + @IsNotEmpty() + @IsString() + SIGNED_COOKIE_FEEDBACK_NAME: string; + + @IsNotEmpty() + @IsNumber() + @Min(1) + SIGNED_COOKIE_FEEDBACK_EXPIRES_IN_MS: number; + + @IsNotEmpty() + @IsString() + SIGNED_COOKIE_CART_NAME: string; + + @IsNotEmpty() + @IsNumber() + @Min(1) + SIGNED_COOKIE_CART_EXPIRES_IN_MS: number; + + @IsNotEmpty() + @IsString() + SIGNED_COOKIE_CAPTCHA_NAME: string; + + @IsNotEmpty() + @IsNumber() + @Min(1) + SIGNED_COOKIE_CAPTCHA_EXPIRES_IN_MS: number; + + @IsNotEmpty() + @IsString() + SIGNED_COOKIE_DISCOUNT_NAME: string; + + @IsNotEmpty() + @IsNumber() + @Min(1) + SIGNED_COOKIE_DISCOUNT_EXPIRES_IN_MS: number; + + @IsNotEmpty() + @IsString() + SIGNED_COOKIE_ERROR_NAME: string; + + @IsNotEmpty() + @IsNumber() + @Min(1) + SIGNED_COOKIE_ERROR_EXPIRES_IN_MS: number; + + @IsNotEmpty() + @IsString() + SIGNED_COOKIE_CHECKOUT_SESSION_NAME: string; + + @IsNotEmpty() + @IsString() + SIGNED_COOKIE_ORDER_AUTH_NAME: string; + + @IsNotEmpty() + @IsNumber() + @Min(1) + SIGNED_COOKIE_ORDER_AUTH_EXPIRES_IN_MS: number; + + @IsNotEmpty() + @IsString() + SIGNED_COOKIE_THEME_NAME: string; + + @IsNotEmpty() + @IsNumber() + @Min(1) + SIGNED_COOKIE_THEME_EXPIRES_IN_MS: number; + + @IsNotEmpty() + @IsString() + COINGECKO_API_BASE_URL: string; + + @IsNotEmpty() + @IsString() + KRAKEN_API_BASE_URL: string; + + @IsNotEmpty() + @IsNumber() + @Min(1) + KRAKEN_XMR_RATE_FETCH_TIMEOUT_MS: number; + + @IsNotEmpty() + @IsNumber() + @Min(1) + COINGECKO_XMR_RATE_FETCH_TIMEOUT_MS: number; + + @IsNotEmpty() + @IsString() + @IsBase64({ byteLength: 32 }) + BASE64_ENCRYPTION_KEY: string; + + @IsNotEmpty() + @IsString() + @IsMoneroConfirmationTiers() + MONERO_CONFIRMATION_TIERS: string; + + @IsNotEmpty() + @IsNumber() + @Min(60000) + ORDER_CHECKOUT_VALIDITY_MS: number; + + @IsNotEmpty() + @IsNumber() + @Min(1) + ORDER_CHECKOUT_STATUS_REFRESH_SEC: number; + + @IsNotEmpty() + @IsNumber() + @Min(60000) + ORDER_SHIPPING_PAYMENT_VALIDITY_MS: number; + + @IsNotEmpty() + @IsNumber() + @Min(1) + ORDER_DATA_RETENTION_DAYS: number; + + @IsNotEmpty() + @IsNumber() + @Min(0) + @Max(Number.MAX_SAFE_INTEGER) + MONERO_MIN_INCOMING_ATOMIC: number; + + @IsNotEmpty() + @IsString() + MONERO_DAEMON_ADDRESS: string; + + @IsNotEmpty() + @IsEnum(MoneroNetwork) + MONERO_NETWORK: MoneroNetwork; + + @IsNotEmpty() + @IsString() + MONERO_WALLET_RPC_HOST: string; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(65535) + MONERO_WALLET_RPC_PORT: number; + + @IsNotEmpty() + @IsString() + MONERO_WALLET_RPC_USERNAME: string; + + @IsNotEmpty() + @IsString() + MONERO_WALLET_RPC_PASSWORD: string; + + @IsNotEmpty() + @IsNumber() + @Min(1000) + MONERO_WALLET_RPC_TIMEOUT_MS: number; + + @IsNotEmpty() + @IsString() + SIMPLEX_WS_URL: string; + + @IsNotEmpty() + @IsString() + SIMPLEX_BOT_DISPLAY_NAME: string; +} + +export const validate = (config: Record) => { + const validatedConfig = plainToClass(EnvironmentVariables, config, { + enableImplicitConversion: true + }); + + const errors = validateSync(validatedConfig, { + skipMissingProperties: false + }); + + if (errors.length > 0) { + throw new Error(errors.toString()); + } + + return validatedConfig; +}; diff --git a/backend/src/consts/shopSurfaceHeader.ts b/backend/src/consts/shopSurfaceHeader.ts new file mode 100644 index 0000000..0466bf5 --- /dev/null +++ b/backend/src/consts/shopSurfaceHeader.ts @@ -0,0 +1 @@ +export const SHOP_SURFACE_HEADER_NAME = 'x-shop-surface'; diff --git a/backend/src/consts/storefrontOrderPageAnchor.ts b/backend/src/consts/storefrontOrderPageAnchor.ts new file mode 100644 index 0000000..c8de33d --- /dev/null +++ b/backend/src/consts/storefrontOrderPageAnchor.ts @@ -0,0 +1,5 @@ +export const StorefrontOrderPageAnchor = { + Chat: 'order-chat', + CheckoutPayment: 'order-checkout-payment', + ShippingPayment: 'order-shipping-payment' +} as const; diff --git a/backend/src/consts/storefrontOrderRefreshSection.ts b/backend/src/consts/storefrontOrderRefreshSection.ts new file mode 100644 index 0000000..448a8b4 --- /dev/null +++ b/backend/src/consts/storefrontOrderRefreshSection.ts @@ -0,0 +1,5 @@ +export const StorefrontOrderRefreshSection = { + Chat: 'chat', + CheckoutPayment: 'checkout-payment', + ShippingPayment: 'shipping-payment' +} as const; diff --git a/backend/src/consts/xmrAtomicPerXmr.ts b/backend/src/consts/xmrAtomicPerXmr.ts new file mode 100644 index 0000000..297a8f2 --- /dev/null +++ b/backend/src/consts/xmrAtomicPerXmr.ts @@ -0,0 +1,3 @@ +import Decimal from 'decimal.js'; + +export const XMR_ATOMIC_PER_XMR = new Decimal(1_000_000_000_000); diff --git a/backend/src/database/DataSource.ts b/backend/src/database/DataSource.ts new file mode 100644 index 0000000..a334f4d --- /dev/null +++ b/backend/src/database/DataSource.ts @@ -0,0 +1,5 @@ +import { DataSource } from 'typeorm'; + +import { getPostgresConfig } from '../config'; + +export default new DataSource(getPostgresConfig()); diff --git a/backend/src/database/migrations/1777811112018-init-products-crud.ts b/backend/src/database/migrations/1777811112018-init-products-crud.ts new file mode 100644 index 0000000..b8f1c37 --- /dev/null +++ b/backend/src/database/migrations/1777811112018-init-products-crud.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class InitProductsCrud1777811112018 implements MigrationInterface { + name = 'InitProductsCrud1777811112018'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "digital_stock_items" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "content" text NOT NULL, "isSold" boolean NOT NULL DEFAULT false, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP, "productId" uuid, CONSTRAINT "PK_bfffac39999d57b14d445abeb2f" PRIMARY KEY ("id"))` + ); + await queryRunner.query(`CREATE TYPE "public"."products_priceunit_enum" AS ENUM('USD')`); + await queryRunner.query(`CREATE TYPE "public"."products_type_enum" AS ENUM('digital')`); + await queryRunner.query( + `CREATE TABLE "products" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying NOT NULL, "price" numeric(12,2) NOT NULL, "priceUnit" "public"."products_priceunit_enum" NOT NULL DEFAULT 'USD', "type" "public"."products_type_enum" NOT NULL DEFAULT 'digital', "thumbnailUrl" character varying NOT NULL, "descriptionHtml" text NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP, CONSTRAINT "PK_0806c755e0aca124e67c0cf6d7d" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `ALTER TABLE "digital_stock_items" ADD CONSTRAINT "FK_f3ee7be0d350b6954b5d3c8bf32" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "digital_stock_items" DROP CONSTRAINT "FK_f3ee7be0d350b6954b5d3c8bf32"`); + await queryRunner.query(`DROP TABLE "products"`); + await queryRunner.query(`DROP TYPE "public"."products_type_enum"`); + await queryRunner.query(`DROP TYPE "public"."products_priceunit_enum"`); + await queryRunner.query(`DROP TABLE "digital_stock_items"`); + } +} diff --git a/backend/src/database/migrations/1778247115870-make-product-as-draft-initialy.ts b/backend/src/database/migrations/1778247115870-make-product-as-draft-initialy.ts new file mode 100644 index 0000000..0e0bcc7 --- /dev/null +++ b/backend/src/database/migrations/1778247115870-make-product-as-draft-initialy.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MakeProductAsDraftInitialy1778247115870 implements MigrationInterface { + name = 'MakeProductAsDraftInitialy1778247115870'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "products" ADD "isDraft" boolean NOT NULL DEFAULT true`); + await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "title" SET DEFAULT ''`); + await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "price" SET DEFAULT '0'`); + await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "thumbnailUrl" SET DEFAULT ''`); + await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "descriptionHtml" SET DEFAULT ''`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "descriptionHtml" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "thumbnailUrl" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "price" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "title" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "products" DROP COLUMN "isDraft"`); + } +} diff --git a/backend/src/database/migrations/1779658508834-add-physical-product-type.ts b/backend/src/database/migrations/1779658508834-add-physical-product-type.ts new file mode 100644 index 0000000..02f7b2b --- /dev/null +++ b/backend/src/database/migrations/1779658508834-add-physical-product-type.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPhysicalProductType1779658508834 implements MigrationInterface { + name = 'AddPhysicalProductType1779658508834'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "products" ADD "stockQuantity" integer`); + await queryRunner.query(`ALTER TYPE "public"."products_type_enum" RENAME TO "products_type_enum_old"`); + await queryRunner.query(`CREATE TYPE "public"."products_type_enum" AS ENUM('digital', 'physical')`); + await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "type" DROP DEFAULT`); + await queryRunner.query( + `ALTER TABLE "products" ALTER COLUMN "type" TYPE "public"."products_type_enum" USING "type"::"text"::"public"."products_type_enum"` + ); + await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "type" SET DEFAULT 'digital'`); + await queryRunner.query(`DROP TYPE "public"."products_type_enum_old"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "public"."products_type_enum_old" AS ENUM('digital')`); + await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "type" DROP DEFAULT`); + await queryRunner.query( + `ALTER TABLE "products" ALTER COLUMN "type" TYPE "public"."products_type_enum_old" USING "type"::"text"::"public"."products_type_enum_old"` + ); + await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "type" SET DEFAULT 'digital'`); + await queryRunner.query(`DROP TYPE "public"."products_type_enum"`); + await queryRunner.query(`ALTER TYPE "public"."products_type_enum_old" RENAME TO "products_type_enum"`); + await queryRunner.query(`ALTER TABLE "products" DROP COLUMN "stockQuantity"`); + } +} diff --git a/backend/src/database/migrations/1779721786152-remove-price-unit-product-col.ts b/backend/src/database/migrations/1779721786152-remove-price-unit-product-col.ts new file mode 100644 index 0000000..624c354 --- /dev/null +++ b/backend/src/database/migrations/1779721786152-remove-price-unit-product-col.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemovePriceUnitProductCol1779721786152 implements MigrationInterface { + name = 'RemovePriceUnitProductCol1779721786152'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "products" DROP COLUMN "priceUnit"`); + await queryRunner.query(`DROP TYPE "public"."products_priceunit_enum"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "public"."products_priceunit_enum" AS ENUM('USD')`); + await queryRunner.query( + `ALTER TABLE "products" ADD "priceUnit" "public"."products_priceunit_enum" NOT NULL DEFAULT 'USD'` + ); + } +} diff --git a/backend/src/database/migrations/1780672489601-add-discount-code-entity.ts b/backend/src/database/migrations/1780672489601-add-discount-code-entity.ts new file mode 100644 index 0000000..2ee8ac3 --- /dev/null +++ b/backend/src/database/migrations/1780672489601-add-discount-code-entity.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDiscountCodeEntity1780672489601 implements MigrationInterface { + name = 'AddDiscountCodeEntity1780672489601'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "public"."discount_codes_type_enum" AS ENUM('percent', 'fixed')`); + await queryRunner.query( + `CREATE TABLE "discount_codes" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "code" character varying NOT NULL, "type" "public"."discount_codes_type_enum" NOT NULL, "value" numeric(12,2) NOT NULL, "isActive" boolean NOT NULL DEFAULT true, "validFrom" TIMESTAMP WITH TIME ZONE, "validUntil" TIMESTAMP WITH TIME ZONE, "maxRedemptions" integer, "redemptionCount" integer NOT NULL DEFAULT '0', "minOrderAmount" numeric(12,2), "isExclusive" boolean NOT NULL DEFAULT false, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP, CONSTRAINT "UQ_b967edd0d46547d4a92b4a1c6b3" UNIQUE ("code"), CONSTRAINT "PK_c0170a28d937472e9ce50fdce17" PRIMARY KEY ("id"))` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "discount_codes"`); + await queryRunner.query(`DROP TYPE "public"."discount_codes_type_enum"`); + } +} diff --git a/backend/src/database/migrations/1781106764300-use-partial-unique-index-instead-of-unique-constraint-on-discount.ts b/backend/src/database/migrations/1781106764300-use-partial-unique-index-instead-of-unique-constraint-on-discount.ts new file mode 100644 index 0000000..a7caffc --- /dev/null +++ b/backend/src/database/migrations/1781106764300-use-partial-unique-index-instead-of-unique-constraint-on-discount.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UsePartialUniqueIndexInsteadOfUniqueConstraintOnDiscount1781106764300 implements MigrationInterface { + name = 'UsePartialUniqueIndexInsteadOfUniqueConstraintOnDiscount1781106764300'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "discount_codes" DROP CONSTRAINT "UQ_b967edd0d46547d4a92b4a1c6b3"`); + await queryRunner.query( + `CREATE UNIQUE INDEX "UQ_discount_codes_code_active" ON "discount_codes" ("code") WHERE "deletedAt" IS NULL` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."UQ_discount_codes_code_active"`); + await queryRunner.query( + `ALTER TABLE "discount_codes" ADD CONSTRAINT "UQ_b967edd0d46547d4a92b4a1c6b3" UNIQUE ("code")` + ); + } +} diff --git a/backend/src/database/migrations/1781195026200-add-many-to-many-discounts-to-products.ts b/backend/src/database/migrations/1781195026200-add-many-to-many-discounts-to-products.ts new file mode 100644 index 0000000..a949f38 --- /dev/null +++ b/backend/src/database/migrations/1781195026200-add-many-to-many-discounts-to-products.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddManyToManyDiscountsToProducts1781195026200 implements MigrationInterface { + name = 'AddManyToManyDiscountsToProducts1781195026200'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "discount_codes_products" ("discountCodeId" uuid NOT NULL, "productId" uuid NOT NULL, CONSTRAINT "PK_ddb3803ddbde34f384005c7e83c" PRIMARY KEY ("discountCodeId", "productId"))` + ); + await queryRunner.query( + `CREATE INDEX "IDX_9c88bd20ae9779f6ffe41989f5" ON "discount_codes_products" ("discountCodeId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_fc3c8861bdc81589f933b5cee5" ON "discount_codes_products" ("productId") ` + ); + await queryRunner.query( + `ALTER TABLE "discount_codes_products" ADD CONSTRAINT "FK_9c88bd20ae9779f6ffe41989f5a" FOREIGN KEY ("discountCodeId") REFERENCES "discount_codes"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE "discount_codes_products" ADD CONSTRAINT "FK_fc3c8861bdc81589f933b5cee5e" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "discount_codes_products" DROP CONSTRAINT "FK_fc3c8861bdc81589f933b5cee5e"` + ); + await queryRunner.query( + `ALTER TABLE "discount_codes_products" DROP CONSTRAINT "FK_9c88bd20ae9779f6ffe41989f5a"` + ); + await queryRunner.query(`DROP INDEX "public"."IDX_fc3c8861bdc81589f933b5cee5"`); + await queryRunner.query(`DROP INDEX "public"."IDX_9c88bd20ae9779f6ffe41989f5"`); + await queryRunner.query(`DROP TABLE "discount_codes_products"`); + } +} diff --git a/backend/src/database/migrations/1781785655067-add-product-categories.ts b/backend/src/database/migrations/1781785655067-add-product-categories.ts new file mode 100644 index 0000000..3f11ec6 --- /dev/null +++ b/backend/src/database/migrations/1781785655067-add-product-categories.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddProductCategories1781785655067 implements MigrationInterface { + name = 'AddProductCategories1781785655067'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "categories" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "sortOrder" integer NOT NULL DEFAULT '0', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_8b0be371d28245da6e4f4b61878" UNIQUE ("name"), CONSTRAINT "PK_24dbc6126a28ff948da33e97d3b" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE TABLE "products_categories" ("productId" uuid NOT NULL, "categoryId" uuid NOT NULL, CONSTRAINT "PK_ac147b513b3ed1671ef3577e098" PRIMARY KEY ("productId", "categoryId"))` + ); + await queryRunner.query( + `CREATE INDEX "IDX_b7bcf72f50cb6aca555a72eb63" ON "products_categories" ("productId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_10e0ebf99c4b77d7d726036d8e" ON "products_categories" ("categoryId") ` + ); + await queryRunner.query( + `ALTER TABLE "products_categories" ADD CONSTRAINT "FK_b7bcf72f50cb6aca555a72eb630" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE "products_categories" ADD CONSTRAINT "FK_10e0ebf99c4b77d7d726036d8ec" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "products_categories" DROP CONSTRAINT "FK_10e0ebf99c4b77d7d726036d8ec"`); + await queryRunner.query(`ALTER TABLE "products_categories" DROP CONSTRAINT "FK_b7bcf72f50cb6aca555a72eb630"`); + await queryRunner.query(`DROP INDEX "public"."IDX_10e0ebf99c4b77d7d726036d8e"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b7bcf72f50cb6aca555a72eb63"`); + await queryRunner.query(`DROP TABLE "products_categories"`); + await queryRunner.query(`DROP TABLE "categories"`); + } +} diff --git a/backend/src/database/migrations/1781849627565-add-discount-to-category.ts b/backend/src/database/migrations/1781849627565-add-discount-to-category.ts new file mode 100644 index 0000000..4286646 --- /dev/null +++ b/backend/src/database/migrations/1781849627565-add-discount-to-category.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDiscountToCategory1781849627565 implements MigrationInterface { + name = 'AddDiscountToCategory1781849627565'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "discount_codes_categories" ("discountCodeId" uuid NOT NULL, "categoryId" uuid NOT NULL, CONSTRAINT "PK_a1d001587e1735737d233e956a5" PRIMARY KEY ("discountCodeId", "categoryId"))` + ); + await queryRunner.query( + `CREATE INDEX "IDX_3126372e862e29a5374a8594b0" ON "discount_codes_categories" ("discountCodeId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_1bcf070115fa8dc5ebb57d0fee" ON "discount_codes_categories" ("categoryId") ` + ); + await queryRunner.query( + `ALTER TABLE "discount_codes_categories" ADD CONSTRAINT "FK_3126372e862e29a5374a8594b0c" FOREIGN KEY ("discountCodeId") REFERENCES "discount_codes"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE "discount_codes_categories" ADD CONSTRAINT "FK_1bcf070115fa8dc5ebb57d0fee8" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "discount_codes_categories" DROP CONSTRAINT "FK_1bcf070115fa8dc5ebb57d0fee8"` + ); + await queryRunner.query( + `ALTER TABLE "discount_codes_categories" DROP CONSTRAINT "FK_3126372e862e29a5374a8594b0c"` + ); + await queryRunner.query(`DROP INDEX "public"."IDX_1bcf070115fa8dc5ebb57d0fee"`); + await queryRunner.query(`DROP INDEX "public"."IDX_3126372e862e29a5374a8594b0"`); + await queryRunner.query(`DROP TABLE "discount_codes_categories"`); + } +} diff --git a/backend/src/database/migrations/1782151519819-add-product-variants-table.ts b/backend/src/database/migrations/1782151519819-add-product-variants-table.ts new file mode 100644 index 0000000..e7590f3 --- /dev/null +++ b/backend/src/database/migrations/1782151519819-add-product-variants-table.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddProductVariantsTable1782151519819 implements MigrationInterface { + name = 'AddProductVariantsTable1782151519819'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "product_variants" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying NOT NULL, "price" numeric(12,2) NOT NULL DEFAULT '0', "stockQuantity" integer, "isDefault" boolean NOT NULL DEFAULT false, "sortOrder" integer NOT NULL DEFAULT '0', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "productId" uuid, CONSTRAINT "PK_281e3f2c55652d6a22c0aa59fd7" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "UQ_product_variants_default_per_product" ON "product_variants" ("productId") WHERE "isDefault" = true` + ); + await queryRunner.query(`ALTER TABLE "products" DROP COLUMN "price"`); + await queryRunner.query(`ALTER TABLE "products" DROP COLUMN "stockQuantity"`); + await queryRunner.query( + `ALTER TABLE "product_variants" ADD CONSTRAINT "FK_f515690c571a03400a9876600b5" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "product_variants" DROP CONSTRAINT "FK_f515690c571a03400a9876600b5"`); + await queryRunner.query(`ALTER TABLE "products" ADD "stockQuantity" integer`); + await queryRunner.query(`ALTER TABLE "products" ADD "price" numeric(12,2) NOT NULL DEFAULT '0'`); + await queryRunner.query(`DROP INDEX "public"."UQ_product_variants_default_per_product"`); + await queryRunner.query(`DROP TABLE "product_variants"`); + } +} diff --git a/backend/src/database/migrations/1782652984862-add-discount-code-variants-m2m.ts b/backend/src/database/migrations/1782652984862-add-discount-code-variants-m2m.ts new file mode 100644 index 0000000..0ae1d2b --- /dev/null +++ b/backend/src/database/migrations/1782652984862-add-discount-code-variants-m2m.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDiscountCodeVariantsM2m1782652984862 implements MigrationInterface { + name = 'AddDiscountCodeVariantsM2m1782652984862'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "discount_codes_variants" ("discountCodeId" uuid NOT NULL, "variantId" uuid NOT NULL, CONSTRAINT "PK_64381aa5f72cc68f7fdea1f43cc" PRIMARY KEY ("discountCodeId", "variantId"))` + ); + await queryRunner.query( + `CREATE INDEX "IDX_e7995d0deea26f2a89662b81ad" ON "discount_codes_variants" ("discountCodeId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_04bd8f37b20eddfd6fc1c2c8aa" ON "discount_codes_variants" ("variantId") ` + ); + await queryRunner.query( + `ALTER TABLE "discount_codes_variants" ADD CONSTRAINT "FK_e7995d0deea26f2a89662b81ada" FOREIGN KEY ("discountCodeId") REFERENCES "discount_codes"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE "discount_codes_variants" ADD CONSTRAINT "FK_04bd8f37b20eddfd6fc1c2c8aa0" FOREIGN KEY ("variantId") REFERENCES "product_variants"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "discount_codes_variants" DROP CONSTRAINT "FK_04bd8f37b20eddfd6fc1c2c8aa0"` + ); + await queryRunner.query( + `ALTER TABLE "discount_codes_variants" DROP CONSTRAINT "FK_e7995d0deea26f2a89662b81ada"` + ); + await queryRunner.query(`DROP INDEX "public"."IDX_04bd8f37b20eddfd6fc1c2c8aa"`); + await queryRunner.query(`DROP INDEX "public"."IDX_e7995d0deea26f2a89662b81ad"`); + await queryRunner.query(`DROP TABLE "discount_codes_variants"`); + } +} diff --git a/backend/src/database/migrations/1782821226132-move-digital-stock-items-to-variant-scope.ts b/backend/src/database/migrations/1782821226132-move-digital-stock-items-to-variant-scope.ts new file mode 100644 index 0000000..b7bd905 --- /dev/null +++ b/backend/src/database/migrations/1782821226132-move-digital-stock-items-to-variant-scope.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MoveDigitalStockItemsToVariantScope1782821226132 implements MigrationInterface { + name = 'MoveDigitalStockItemsToVariantScope1782821226132'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "digital_stock_items" DROP CONSTRAINT "FK_f3ee7be0d350b6954b5d3c8bf32"`); + await queryRunner.query(`ALTER TABLE "digital_stock_items" RENAME COLUMN "productId" TO "variantId"`); + await queryRunner.query( + `ALTER TABLE "digital_stock_items" ADD CONSTRAINT "FK_f93fdc4b42cf0410d8bb0c66778" FOREIGN KEY ("variantId") REFERENCES "product_variants"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "digital_stock_items" DROP CONSTRAINT "FK_f93fdc4b42cf0410d8bb0c66778"`); + await queryRunner.query(`ALTER TABLE "digital_stock_items" RENAME COLUMN "variantId" TO "productId"`); + await queryRunner.query( + `ALTER TABLE "digital_stock_items" ADD CONSTRAINT "FK_f3ee7be0d350b6954b5d3c8bf32" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ); + } +} diff --git a/backend/src/database/migrations/1782839159240-remove-default-variant.ts b/backend/src/database/migrations/1782839159240-remove-default-variant.ts new file mode 100644 index 0000000..2ac765c --- /dev/null +++ b/backend/src/database/migrations/1782839159240-remove-default-variant.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveDefaultVariant1782839159240 implements MigrationInterface { + name = 'RemoveDefaultVariant1782839159240'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."UQ_product_variants_default_per_product"`); + await queryRunner.query(`ALTER TABLE "product_variants" DROP COLUMN "isDefault"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "product_variants" ADD "isDefault" boolean NOT NULL DEFAULT false`); + await queryRunner.query( + `CREATE UNIQUE INDEX "UQ_product_variants_default_per_product" ON "product_variants" ("productId") WHERE ("isDefault" = true)` + ); + } +} diff --git a/backend/src/database/migrations/1783206651562-add-variant-images.ts b/backend/src/database/migrations/1783206651562-add-variant-images.ts new file mode 100644 index 0000000..af68144 --- /dev/null +++ b/backend/src/database/migrations/1783206651562-add-variant-images.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddVariantImages1783206651562 implements MigrationInterface { + name = 'AddVariantImages1783206651562'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "variant_images" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "url" character varying NOT NULL, "sortOrder" integer NOT NULL DEFAULT '0', "isThumbnail" boolean NOT NULL DEFAULT false, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "variantId" uuid, CONSTRAINT "PK_e12e3f22db0e085fb8cb0a43fb7" PRIMARY KEY ("id"))` + ); + await queryRunner.query(`ALTER TABLE "products" DROP COLUMN "thumbnailUrl"`); + await queryRunner.query( + `ALTER TABLE "variant_images" ADD CONSTRAINT "FK_a64680112bb0dc25d6cc0747d4c" FOREIGN KEY ("variantId") REFERENCES "product_variants"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "variant_images" DROP CONSTRAINT "FK_a64680112bb0dc25d6cc0747d4c"`); + await queryRunner.query(`ALTER TABLE "products" ADD "thumbnailUrl" character varying NOT NULL DEFAULT ''`); + await queryRunner.query(`DROP TABLE "variant_images"`); + } +} diff --git a/backend/src/database/migrations/1783522977172-product-type-rework.ts b/backend/src/database/migrations/1783522977172-product-type-rework.ts new file mode 100644 index 0000000..3cc6042 --- /dev/null +++ b/backend/src/database/migrations/1783522977172-product-type-rework.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ProductTypeRework1783522977172 implements MigrationInterface { + name = 'ProductTypeRework1783522977172'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "products" RENAME COLUMN "type" TO "deliveryMode"`); + await queryRunner.query(`ALTER TYPE "public"."products_type_enum" RENAME TO "products_deliverymode_enum"`); + await queryRunner.query( + `ALTER TYPE "public"."products_deliverymode_enum" RENAME TO "products_deliverymode_enum_old"` + ); + await queryRunner.query(`CREATE TYPE "public"."products_deliverymode_enum" AS ENUM('auto', 'manual')`); + await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "deliveryMode" DROP DEFAULT`); + await queryRunner.query( + `ALTER TABLE "products" ALTER COLUMN "deliveryMode" TYPE "public"."products_deliverymode_enum" USING "deliveryMode"::"text"::"public"."products_deliverymode_enum"` + ); + await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "deliveryMode" SET DEFAULT 'auto'`); + await queryRunner.query(`DROP TYPE "public"."products_deliverymode_enum_old"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "public"."products_deliverymode_enum_old" AS ENUM('digital', 'physical')`); + await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "deliveryMode" DROP DEFAULT`); + await queryRunner.query( + `ALTER TABLE "products" ALTER COLUMN "deliveryMode" TYPE "public"."products_deliverymode_enum_old" USING "deliveryMode"::"text"::"public"."products_deliverymode_enum_old"` + ); + await queryRunner.query(`ALTER TABLE "products" ALTER COLUMN "deliveryMode" SET DEFAULT 'digital'`); + await queryRunner.query(`DROP TYPE "public"."products_deliverymode_enum"`); + await queryRunner.query( + `ALTER TYPE "public"."products_deliverymode_enum_old" RENAME TO "products_deliverymode_enum"` + ); + await queryRunner.query(`ALTER TYPE "public"."products_deliverymode_enum" RENAME TO "products_type_enum"`); + await queryRunner.query(`ALTER TABLE "products" RENAME COLUMN "deliveryMode" TO "type"`); + } +} diff --git a/backend/src/database/migrations/1783610487305-add-digital-stock-attachments.ts b/backend/src/database/migrations/1783610487305-add-digital-stock-attachments.ts new file mode 100644 index 0000000..4646cb5 --- /dev/null +++ b/backend/src/database/migrations/1783610487305-add-digital-stock-attachments.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDigitalStockAttachments1783610487305 implements MigrationInterface { + name = 'AddDigitalStockAttachments1783610487305'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "digital_stock_item_attachments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "storageKey" character varying NOT NULL, "originalFilename" character varying NOT NULL, "mimeType" character varying NOT NULL, "sizeBytes" integer NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "digitalStockItemId" uuid, CONSTRAINT "PK_8ba50fc3e4cc64e54fb7494f5a1" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `ALTER TABLE "digital_stock_item_attachments" ADD CONSTRAINT "FK_0cc31f343c2efcd598b53e9537d" FOREIGN KEY ("digitalStockItemId") REFERENCES "digital_stock_items"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "digital_stock_item_attachments" DROP CONSTRAINT "FK_0cc31f343c2efcd598b53e9537d"` + ); + await queryRunner.query(`DROP TABLE "digital_stock_item_attachments"`); + } +} diff --git a/backend/src/database/migrations/1783701820648-remove-soft-delete-cols.ts b/backend/src/database/migrations/1783701820648-remove-soft-delete-cols.ts new file mode 100644 index 0000000..50eedc5 --- /dev/null +++ b/backend/src/database/migrations/1783701820648-remove-soft-delete-cols.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveSoftDeleteCols1783701820648 implements MigrationInterface { + name = 'RemoveSoftDeleteCols1783701820648'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."UQ_discount_codes_code_active"`); + await queryRunner.query(`ALTER TABLE "digital_stock_items" DROP COLUMN "deletedAt"`); + await queryRunner.query(`ALTER TABLE "products" DROP COLUMN "deletedAt"`); + await queryRunner.query(`ALTER TABLE "discount_codes" DROP COLUMN "deletedAt"`); + await queryRunner.query( + `ALTER TABLE "discount_codes" ADD CONSTRAINT "UQ_b967edd0d46547d4a92b4a1c6b3" UNIQUE ("code")` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "discount_codes" DROP CONSTRAINT "UQ_b967edd0d46547d4a92b4a1c6b3"`); + await queryRunner.query(`ALTER TABLE "discount_codes" ADD "deletedAt" TIMESTAMP`); + await queryRunner.query(`ALTER TABLE "products" ADD "deletedAt" TIMESTAMP`); + await queryRunner.query(`ALTER TABLE "digital_stock_items" ADD "deletedAt" TIMESTAMP`); + await queryRunner.query( + `CREATE UNIQUE INDEX "UQ_discount_codes_code_active" ON "discount_codes" ("code") WHERE ("deletedAt" IS NULL)` + ); + } +} diff --git a/backend/src/database/migrations/1783806278430-add-shop-settings.ts b/backend/src/database/migrations/1783806278430-add-shop-settings.ts new file mode 100644 index 0000000..c8931d5 --- /dev/null +++ b/backend/src/database/migrations/1783806278430-add-shop-settings.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddShopSettings1783806278430 implements MigrationInterface { + name = 'AddShopSettings1783806278430'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "shop_settings" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "logoStorageKey" character varying, "simplexLink" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_96c0aa2a327de09ebe0fd54ae5a" PRIMARY KEY ("id"))` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "shop_settings"`); + } +} diff --git a/backend/src/database/migrations/1783912100000-add-shipping-note.ts b/backend/src/database/migrations/1783912100000-add-shipping-note.ts new file mode 100644 index 0000000..1febd5d --- /dev/null +++ b/backend/src/database/migrations/1783912100000-add-shipping-note.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddShippingNote1783912100000 implements MigrationInterface { + name = 'AddShippingNote1783912100000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "shop_settings" ADD "shippingNote" text`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "shop_settings" DROP COLUMN "shippingNote"`); + } +} diff --git a/backend/src/database/migrations/1784030873071-add-commerce-tables.ts b/backend/src/database/migrations/1784030873071-add-commerce-tables.ts new file mode 100644 index 0000000..5bc27fe --- /dev/null +++ b/backend/src/database/migrations/1784030873071-add-commerce-tables.ts @@ -0,0 +1,161 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCommerceTables1784030873071 implements MigrationInterface { + name = 'AddCommerceTables1784030873071'; + + public async up(queryRunner: QueryRunner): Promise { + // --- invoices --- + await queryRunner.query(`CREATE TYPE "public"."invoices_reason_enum" AS ENUM('checkout', 'shipping')`); + await queryRunner.query(`CREATE TYPE "public"."invoices_paymentmethod_enum" AS ENUM('xmr')`); + await queryRunner.query( + `CREATE TABLE "invoices" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "reason" "public"."invoices_reason_enum" NOT NULL, "paymentMethod" "public"."invoices_paymentmethod_enum" NOT NULL, "amountFiat" numeric(12,2) NOT NULL, "fiatCurrency" character varying(3) NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, "paymentAddress" character varying(255) NOT NULL, "expectedTotalAtomic" bigint NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_invoices_payment_address" UNIQUE ("paymentAddress"), CONSTRAINT "PK_invoices" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE TABLE "invoice_payments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "txHash" character varying(64) NOT NULL, "amountAtomic" bigint NOT NULL, "confirmations" integer NOT NULL DEFAULT '0', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "invoiceId" uuid, CONSTRAINT "UQ_invoice_payments_tx_hash" UNIQUE ("txHash"), CONSTRAINT "PK_invoice_payments" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE TABLE "invoice_monero_details" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "paymentAddressIndex" integer NOT NULL, "fiatPerXmrAtCreation" numeric(12,2) NOT NULL, "requiredConfirmations" integer NOT NULL, "invoiceId" uuid, CONSTRAINT "UQ_invoice_monero_details_invoice_id" UNIQUE ("invoiceId"), CONSTRAINT "PK_invoice_monero_details" PRIMARY KEY ("id"))` + ); + + // --- orders --- + await queryRunner.query( + `CREATE TYPE "public"."orders_failurereason_enum" AS ENUM('stock_unavailable', 'discount_exhausted')` + ); + await queryRunner.query(`CREATE TYPE "public"."order_lines_deliverymode_enum" AS ENUM('auto', 'manual')`); + await queryRunner.query( + `CREATE TYPE "public"."order_line_manual_fulfillments_status_enum" AS ENUM('pending', 'fulfilled')` + ); + await queryRunner.query(`CREATE TYPE "public"."order_messages_sender_enum" AS ENUM('buyer', 'staff')`); + await queryRunner.query( + `CREATE TABLE "orders" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "accessTokenLookup" character varying(64) NOT NULL, "accessToken" text NOT NULL, "failureReason" "public"."orders_failurereason_enum", "accessTokenSavedConfirmedAt" TIMESTAMP WITH TIME ZONE, "quotedAt" TIMESTAMP WITH TIME ZONE, "checkoutSessionId" uuid, "checkoutInvoiceId" uuid, "shippingInvoiceId" uuid, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_43a3cd3d7373e9013a08a6a65b6" UNIQUE ("accessTokenLookup"), CONSTRAINT "UQ_orders_checkout_session_id" UNIQUE ("checkoutSessionId"), CONSTRAINT "UQ_orders_checkout_invoice_id" UNIQUE ("checkoutInvoiceId"), CONSTRAINT "UQ_orders_shipping_invoice_id" UNIQUE ("shippingInvoiceId"), CONSTRAINT "PK_710e2d4957aa5878dfe94e4ac2f" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE TABLE "order_lines" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "variantId" uuid NOT NULL, "productId" uuid NOT NULL, "productTitle" character varying NOT NULL, "variantTitle" character varying NOT NULL, "thumbnailUrl" character varying, "qty" integer NOT NULL, "unitPriceFiat" numeric(12,2) NOT NULL, "lineSubtotalFiat" numeric(12,2) NOT NULL, "deliveryMode" "public"."order_lines_deliverymode_enum" NOT NULL, "orderId" uuid, CONSTRAINT "PK_order_lines" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE TABLE "order_discounts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "code" character varying(32) NOT NULL, "amountFiat" numeric(12,2) NOT NULL, "orderId" uuid, CONSTRAINT "PK_order_discounts" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE TABLE "order_line_auto_fulfillment_items" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "sortOrder" integer NOT NULL, "contentSnapshot" text NOT NULL, "sourceDigitalStockItemId" uuid NOT NULL, "orderLineId" uuid, CONSTRAINT "PK_order_line_auto_fulfillment_items" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE TABLE "order_line_auto_fulfillment_item_attachments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "storageKey" character varying NOT NULL, "sourceDigitalStockAttachmentId" uuid NOT NULL, "originalFilename" character varying NOT NULL, "mimeType" character varying NOT NULL, "sizeBytes" integer NOT NULL, "itemId" uuid, CONSTRAINT "PK_order_line_auto_fulfillment_item_attachments" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE TABLE "order_line_manual_fulfillments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "status" "public"."order_line_manual_fulfillments_status_enum" NOT NULL DEFAULT 'pending', "fulfilledAt" TIMESTAMP WITH TIME ZONE, "orderLineId" uuid, CONSTRAINT "REL_order_line_manual_fulfillments_line" UNIQUE ("orderLineId"), CONSTRAINT "PK_order_line_manual_fulfillments" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE TABLE "order_messages" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "orderId" uuid, "sender" "public"."order_messages_sender_enum" NOT NULL, "body" text NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_order_messages_id" PRIMARY KEY ("id"))` + ); + + // --- checkout sessions (kept after order materialization for lookup) --- + await queryRunner.query( + `CREATE TYPE "public"."checkout_session_lines_deliverymode_enum" AS ENUM('auto', 'manual')` + ); + await queryRunner.query( + `CREATE TABLE "checkout_session_discounts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "code" character varying(32) NOT NULL, "amountFiat" numeric(12,2) NOT NULL, "sessionId" uuid, CONSTRAINT "PK_0378497bbff0d9ff832535f92c7" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE TABLE "checkout_session_lines" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "variantId" uuid NOT NULL, "productId" uuid NOT NULL, "productTitle" character varying NOT NULL, "variantTitle" character varying NOT NULL, "thumbnailUrl" character varying, "qty" integer NOT NULL, "unitPriceFiat" numeric(12,2) NOT NULL, "lineSubtotalFiat" numeric(12,2) NOT NULL, "deliveryMode" "public"."checkout_session_lines_deliverymode_enum" NOT NULL, "sessionId" uuid, CONSTRAINT "PK_ca6db5cbf05b43e759bc4593ca2" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE TABLE "checkout_sessions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "cancelledAt" TIMESTAMP WITH TIME ZONE, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "invoiceId" uuid, CONSTRAINT "UQ_checkout_sessions_invoice_id" UNIQUE ("invoiceId"), CONSTRAINT "PK_5730b2bbc190203a94941d82bd1" PRIMARY KEY ("id"))` + ); + + // --- foreign keys --- + await queryRunner.query( + `ALTER TABLE "invoice_payments" ADD CONSTRAINT "FK_3b2a25d4269ebe9d7ca0c1001d4" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "invoice_monero_details" ADD CONSTRAINT "FK_48fcc6226a94ae8cebc91dd9864" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "orders" ADD CONSTRAINT "FK_ca23270a0d1bfb35b8d8c42346e" FOREIGN KEY ("checkoutInvoiceId") REFERENCES "invoices"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "orders" ADD CONSTRAINT "FK_f4061f63babeff4a3a48cad4708" FOREIGN KEY ("shippingInvoiceId") REFERENCES "invoices"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "order_lines" ADD CONSTRAINT "FK_307e8091afc1a959953d06d5ad1" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "order_discounts" ADD CONSTRAINT "FK_f2655f79325cfb8a9711968f65d" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "order_line_auto_fulfillment_items" ADD CONSTRAINT "FK_e9919aa158c30955c372dfce284" FOREIGN KEY ("orderLineId") REFERENCES "order_lines"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "order_line_auto_fulfillment_item_attachments" ADD CONSTRAINT "FK_2f4f82681c575092f1d11dcb222" FOREIGN KEY ("itemId") REFERENCES "order_line_auto_fulfillment_items"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "order_line_manual_fulfillments" ADD CONSTRAINT "FK_f34278a339a91c0663fb31fd66f" FOREIGN KEY ("orderLineId") REFERENCES "order_lines"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "order_messages" ADD CONSTRAINT "FK_cc8ace314be4d8e541ca2e6b730" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "checkout_session_discounts" ADD CONSTRAINT "FK_5e4f76deb4b4325876c6bbcfe75" FOREIGN KEY ("sessionId") REFERENCES "checkout_sessions"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "checkout_session_lines" ADD CONSTRAINT "FK_4a85a0cc8356f6b2a7e5802a373" FOREIGN KEY ("sessionId") REFERENCES "checkout_sessions"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "checkout_sessions" ADD CONSTRAINT "FK_d795185057be72c7d4053175ba4" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "orders" ADD CONSTRAINT "FK_a50ab800c2c16d9ca50e9cda29e" FOREIGN KEY ("checkoutSessionId") REFERENCES "checkout_sessions"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "orders" DROP CONSTRAINT "FK_a50ab800c2c16d9ca50e9cda29e"`); + await queryRunner.query(`ALTER TABLE "checkout_sessions" DROP CONSTRAINT "FK_d795185057be72c7d4053175ba4"`); + await queryRunner.query( + `ALTER TABLE "checkout_session_lines" DROP CONSTRAINT "FK_4a85a0cc8356f6b2a7e5802a373"` + ); + await queryRunner.query( + `ALTER TABLE "checkout_session_discounts" DROP CONSTRAINT "FK_5e4f76deb4b4325876c6bbcfe75"` + ); + await queryRunner.query(`ALTER TABLE "order_messages" DROP CONSTRAINT "FK_cc8ace314be4d8e541ca2e6b730"`); + await queryRunner.query( + `ALTER TABLE "order_line_manual_fulfillments" DROP CONSTRAINT "FK_f34278a339a91c0663fb31fd66f"` + ); + await queryRunner.query( + `ALTER TABLE "order_line_auto_fulfillment_item_attachments" DROP CONSTRAINT "FK_2f4f82681c575092f1d11dcb222"` + ); + await queryRunner.query( + `ALTER TABLE "order_line_auto_fulfillment_items" DROP CONSTRAINT "FK_e9919aa158c30955c372dfce284"` + ); + await queryRunner.query(`ALTER TABLE "order_discounts" DROP CONSTRAINT "FK_f2655f79325cfb8a9711968f65d"`); + await queryRunner.query(`ALTER TABLE "order_lines" DROP CONSTRAINT "FK_307e8091afc1a959953d06d5ad1"`); + await queryRunner.query(`ALTER TABLE "orders" DROP CONSTRAINT "FK_f4061f63babeff4a3a48cad4708"`); + await queryRunner.query(`ALTER TABLE "orders" DROP CONSTRAINT "FK_ca23270a0d1bfb35b8d8c42346e"`); + await queryRunner.query( + `ALTER TABLE "invoice_monero_details" DROP CONSTRAINT "FK_48fcc6226a94ae8cebc91dd9864"` + ); + await queryRunner.query(`ALTER TABLE "invoice_payments" DROP CONSTRAINT "FK_3b2a25d4269ebe9d7ca0c1001d4"`); + + await queryRunner.query(`DROP TABLE "checkout_sessions"`); + await queryRunner.query(`DROP TABLE "checkout_session_lines"`); + await queryRunner.query(`DROP TYPE "public"."checkout_session_lines_deliverymode_enum"`); + await queryRunner.query(`DROP TABLE "checkout_session_discounts"`); + + await queryRunner.query(`DROP TABLE "order_messages"`); + await queryRunner.query(`DROP TYPE "public"."order_messages_sender_enum"`); + await queryRunner.query(`DROP TABLE "order_line_manual_fulfillments"`); + await queryRunner.query(`DROP TYPE "public"."order_line_manual_fulfillments_status_enum"`); + await queryRunner.query(`DROP TABLE "order_line_auto_fulfillment_item_attachments"`); + await queryRunner.query(`DROP TABLE "order_line_auto_fulfillment_items"`); + await queryRunner.query(`DROP TABLE "order_discounts"`); + await queryRunner.query(`DROP TABLE "order_lines"`); + await queryRunner.query(`DROP TYPE "public"."order_lines_deliverymode_enum"`); + await queryRunner.query(`DROP TABLE "orders"`); + await queryRunner.query(`DROP TYPE "public"."orders_failurereason_enum"`); + + await queryRunner.query(`DROP TABLE "invoice_monero_details"`); + await queryRunner.query(`DROP TABLE "invoice_payments"`); + await queryRunner.query(`DROP TABLE "invoices"`); + await queryRunner.query(`DROP TYPE "public"."invoices_paymentmethod_enum"`); + await queryRunner.query(`DROP TYPE "public"."invoices_reason_enum"`); + } +} diff --git a/backend/src/database/migrations/1784500000000-add-order-staff-chat-last-read-at.ts b/backend/src/database/migrations/1784500000000-add-order-staff-chat-last-read-at.ts new file mode 100644 index 0000000..c9e973b --- /dev/null +++ b/backend/src/database/migrations/1784500000000-add-order-staff-chat-last-read-at.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddOrderStaffChatLastReadAt1784500000000 implements MigrationInterface { + name = 'AddOrderStaffChatLastReadAt1784500000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "orders" ADD "staffChatLastReadAt" TIMESTAMP WITH TIME ZONE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "orders" DROP COLUMN "staffChatLastReadAt"`); + } +} diff --git a/backend/src/database/migrations/1784600000000-add-shop-notification-settings.ts b/backend/src/database/migrations/1784600000000-add-shop-notification-settings.ts new file mode 100644 index 0000000..a299ae9 --- /dev/null +++ b/backend/src/database/migrations/1784600000000-add-shop-notification-settings.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddShopNotificationSettings1784600000000 implements MigrationInterface { + name = 'AddShopNotificationSettings1784600000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "shop_settings" ADD "notificationsEnabled" boolean NOT NULL DEFAULT false` + ); + await queryRunner.query(`ALTER TABLE "shop_settings" ADD "notifyOnNewOrder" boolean NOT NULL DEFAULT true`); + await queryRunner.query(`ALTER TABLE "shop_settings" ADD "notifyOnOrderMessage" boolean NOT NULL DEFAULT true`); + await queryRunner.query(`ALTER TABLE "shop_settings" ADD "simplexNotificationContactId" integer`); + await queryRunner.query(`ALTER TABLE "shop_settings" ADD "simplexNotificationLink" varchar`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "shop_settings" DROP COLUMN "simplexNotificationLink"`); + await queryRunner.query(`ALTER TABLE "shop_settings" DROP COLUMN "simplexNotificationContactId"`); + await queryRunner.query(`ALTER TABLE "shop_settings" DROP COLUMN "notifyOnOrderMessage"`); + await queryRunner.query(`ALTER TABLE "shop_settings" DROP COLUMN "notifyOnNewOrder"`); + await queryRunner.query(`ALTER TABLE "shop_settings" DROP COLUMN "notificationsEnabled"`); + } +} diff --git a/backend/src/database/migrations/1784600000001-initialize-shop-settings.ts b/backend/src/database/migrations/1784600000001-initialize-shop-settings.ts new file mode 100644 index 0000000..258dfb3 --- /dev/null +++ b/backend/src/database/migrations/1784600000001-initialize-shop-settings.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class InitializeShopSettings1784600000001 implements MigrationInterface { + name = 'InitializeShopSettings1784600000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO "shop_settings" ("id") + SELECT uuid_generate_v4() + WHERE NOT EXISTS (SELECT 1 FROM "shop_settings") + `); + } + + public async down(): Promise {} +} diff --git a/backend/src/database/migrations/1784700000000-add-favicon-storage-key.ts b/backend/src/database/migrations/1784700000000-add-favicon-storage-key.ts new file mode 100644 index 0000000..d14546d --- /dev/null +++ b/backend/src/database/migrations/1784700000000-add-favicon-storage-key.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddFaviconStorageKey1784700000000 implements MigrationInterface { + name = 'AddFaviconStorageKey1784700000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "shop_settings" ADD "faviconStorageKey" character varying`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "shop_settings" DROP COLUMN "faviconStorageKey"`); + } +} diff --git a/backend/src/database/utils/drop.ts b/backend/src/database/utils/drop.ts new file mode 100644 index 0000000..e30e4b8 --- /dev/null +++ b/backend/src/database/utils/drop.ts @@ -0,0 +1,58 @@ +import dataSource from '../DataSource'; +import getConfig from '../../config'; +import { getErrorMessage } from '../../utils/getErrorMessage'; +import type { InformationSchemaTableRow } from '../../types/database/InformationSchemaTableRow'; +import type { PgEnumTypeRow } from '../../types/database/PgEnumTypeRow'; +import { NodeEnv } from '../../types/NodeEnv'; + +const { + app: { nodeEnv } +} = getConfig(); + +void (async () => { + if (nodeEnv === NodeEnv.Development) { + console.info('Connecting to the database...'); + + const initializedDataSource = await dataSource.initialize(); + + console.info('Connected to the database.'); + + const tables = await initializedDataSource.query( + `SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public';` + ); + + for (const table of tables) { + try { + await initializedDataSource.query(`DROP TABLE IF EXISTS "${table.table_name}" CASCADE;`); + + console.info(`Dropped table: ${table.table_name}`); + } catch (error) { + console.error(`Failed to drop table: ${table.table_name}: ${getErrorMessage(error)}`); + + return process.exit(1); + } + } + + const enums = await initializedDataSource.query( + `SELECT typname FROM pg_type WHERE typtype = 'e';` + ); + + for (const enumType of enums) { + try { + await initializedDataSource.query(`DROP TYPE IF EXISTS ${enumType.typname} CASCADE;`); + + console.info(`Dropped enum: ${enumType.typname}`); + } catch (error) { + console.error(`Failed to drop enum: ${enumType.typname}: ${getErrorMessage(error)}`); + + return process.exit(1); + } + } + + console.info('All tables and enums have been dropped.'); + + return process.exit(0); + } +})(); diff --git a/backend/src/guards/JwtGuard.ts b/backend/src/guards/JwtGuard.ts new file mode 100644 index 0000000..0739b8d --- /dev/null +++ b/backend/src/guards/JwtGuard.ts @@ -0,0 +1,37 @@ +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { Request } from 'express'; +import * as jwt from 'jsonwebtoken'; +import { Config } from '../types/Config'; + +@Injectable() +export class JwtGuard implements CanActivate { + constructor(private readonly configService: ConfigService) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + + const { secret } = this.configService.get('jwt') as Config['jwt']; + + const tokenPrefix = 'Bearer '; + const raw: unknown = request.cookies.bearer_token; + + if (typeof raw !== 'string' || !raw.startsWith(tokenPrefix)) { + throw new UnauthorizedException(); + } + + const token = raw.slice(tokenPrefix.length).trim(); + + if (!token) { + throw new UnauthorizedException(); + } + + try { + jwt.verify(token, secret); + } catch { + throw new UnauthorizedException(); + } + + return true; + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts new file mode 100644 index 0000000..7ece1ea --- /dev/null +++ b/backend/src/main.ts @@ -0,0 +1,97 @@ +import { RequestMethod, ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import { ConfigService } from '@nestjs/config'; +import cookieParser from 'cookie-parser'; +import hbs from 'hbs'; +import path from 'node:path'; +import { getPublicUploadsDir, publicUploadsUrlPrefix } from './config/uploadPaths'; +import { AppModule } from './AppModule'; +import { registerStorefrontHelpers } from './modules/storefrontCore/utils/registerStorefrontHelpers'; +import { registerStorefrontPartials } from './modules/storefrontCore/utils/registerStorefrontPartials'; +import { shopSurfaceHeaderMiddleware } from './middleware/shopSurfaceHeaderMiddleware'; +import { Config } from './types/Config'; +import { NodeEnv } from './types/NodeEnv'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + const configService = app.get(ConfigService); + + const { port, corsOrigins, nodeEnv } = configService.get('app') as Config['app']; + + app.useStaticAssets(getPublicUploadsDir(), { + prefix: publicUploadsUrlPrefix + }); + + const storefrontPublicDir = path.join(__dirname, 'modules', 'storefrontCore', 'public'); + + app.useStaticAssets(storefrontPublicDir, { + prefix: '/shop/assets' + }); + + app.use(cookieParser()); + + if (nodeEnv === NodeEnv.Production) { + app.use(shopSurfaceHeaderMiddleware); + } + + const storefrontViewsDir = path.join(__dirname, 'modules', 'storefrontCore', 'views'); + + registerStorefrontHelpers(hbs); + registerStorefrontPartials(hbs, path.join(storefrontViewsDir, 'partials')); + + app.setBaseViewsDir(storefrontViewsDir); + app.setViewEngine('hbs'); + + app.set('view options', { layout: 'layouts/shop' }); + app.set('trust proxy', 1); + + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + transformOptions: { enableImplicitConversion: true } + }) + ); + + app.setGlobalPrefix('api', { + exclude: [ + { path: '/', method: RequestMethod.GET }, + { path: 'shop/categories/:id', method: RequestMethod.GET }, + { path: 'shop/products/:id/variants/:variantId', method: RequestMethod.GET }, + { path: 'shop/assets/(.*)', method: RequestMethod.GET }, + { path: 'shop/preferences/theme', method: RequestMethod.POST }, + { path: 'shop/error', method: RequestMethod.GET }, + { path: 'shop/cart', method: RequestMethod.GET }, + { path: 'shop/cart/product', method: RequestMethod.POST }, + { path: 'shop/cart/product/update', method: RequestMethod.POST }, + { path: 'shop/cart/product/remove', method: RequestMethod.POST }, + { path: 'shop/cart/discount', method: RequestMethod.POST }, + { path: 'shop/cart/discount/remove', method: RequestMethod.POST }, + { path: 'shop/checkout/pay', method: RequestMethod.POST }, + { path: 'shop/checkout', method: RequestMethod.GET }, + { path: 'shop/checkout/cancel', method: RequestMethod.POST }, + { path: 'shop/check-order', method: RequestMethod.GET }, + { path: 'shop/check-order', method: RequestMethod.POST }, + { path: 'shop/order/:orderId', method: RequestMethod.GET }, + { path: 'shop/order/:orderId/refresh', method: RequestMethod.GET }, + { path: 'shop/order/:orderId/confirm-access-token-saved', method: RequestMethod.POST }, + { path: 'shop/order/:orderId/logout', method: RequestMethod.POST }, + { path: 'shop/order/:orderId/messages', method: RequestMethod.POST }, + { path: 'shop/order/:orderId/messages/:messageId/delete', method: RequestMethod.POST }, + { + path: 'shop/order/:orderId/attachments/:attachmentId/download', + method: RequestMethod.GET + } + ] + }); + + app.enableCors({ + origin: corsOrigins, + credentials: true + }); + + await app.listen(port); +} + +void bootstrap(); diff --git a/backend/src/middleware/shopSurfaceHeaderMiddleware.ts b/backend/src/middleware/shopSurfaceHeaderMiddleware.ts new file mode 100644 index 0000000..431f5f1 --- /dev/null +++ b/backend/src/middleware/shopSurfaceHeaderMiddleware.ts @@ -0,0 +1,28 @@ +import { BadRequestException } from '@nestjs/common'; +import type { NextFunction, Request, Response } from 'express'; +import { SHOP_SURFACE_HEADER_NAME } from '../consts/shopSurfaceHeader'; +import { ShopSurface } from '../types/ShopSurface'; + +const isHealthCheckPath = (path: string): boolean => path === '/api/health-check' || path === '/api/health-check/'; + +export const shopSurfaceHeaderMiddleware = (req: Request, _res: Response, next: NextFunction): void => { + if (isHealthCheckPath(req.path)) { + next(); + + return; + } + + const raw = req.headers[SHOP_SURFACE_HEADER_NAME]; + + if (!raw || (raw !== ShopSurface.Clearnet && raw !== ShopSurface.Onion)) { + next( + new BadRequestException( + `Missing or invalid ${SHOP_SURFACE_HEADER_NAME} header; expected ${ShopSurface.Clearnet} or ${ShopSurface.Onion}` + ) + ); + + return; + } + + next(); +}; diff --git a/backend/src/modules/auth/AuthModule.ts b/backend/src/modules/auth/AuthModule.ts new file mode 100644 index 0000000..c8d18d2 --- /dev/null +++ b/backend/src/modules/auth/AuthModule.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AuthController } from './controllers/AuthController'; +import { AuthService } from './services/AuthService'; + +@Module({ + controllers: [AuthController], + providers: [AuthService], + exports: [AuthService] +}) +export class AuthModule {} diff --git a/backend/src/modules/auth/controllers/AuthController.ts b/backend/src/modules/auth/controllers/AuthController.ts new file mode 100644 index 0000000..f326b08 --- /dev/null +++ b/backend/src/modules/auth/controllers/AuthController.ts @@ -0,0 +1,34 @@ +import { Body, Controller, HttpStatus, Post, Req, Res } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import type { Request, Response } from 'express'; +import { LoginDto } from '../dto/LoginDto'; +import { AuthService } from '../services/AuthService'; +import { ConfigService } from '@nestjs/config'; +import { Config } from '../../../types/Config'; +import { throttleProfiles } from '../../../config/throttleProfiles'; +import { shouldUseSecureCookie } from '../../../utils/shouldUseSecureCookie'; + +@Controller('auth') +export class AuthController { + constructor( + private readonly authService: AuthService, + private readonly configService: ConfigService + ) {} + + @Post('login') + @Throttle(throttleProfiles.cmsLogin) + login(@Body() { password }: LoginDto, @Res() res: Response, @Req() req: Request) { + const { bearerCookie, cookieExpires } = this.authService.login(password); + + const { nodeEnv } = this.configService.get('app') as Config['app']; + + res.cookie('bearer_token', bearerCookie, { + httpOnly: true, + sameSite: 'strict', + secure: shouldUseSecureCookie(nodeEnv, req), + expires: cookieExpires + }); + + return res.sendStatus(HttpStatus.OK); + } +} diff --git a/backend/src/modules/auth/dto/LoginDto.ts b/backend/src/modules/auth/dto/LoginDto.ts new file mode 100644 index 0000000..25d4341 --- /dev/null +++ b/backend/src/modules/auth/dto/LoginDto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class LoginDto { + @IsString() + @IsNotEmpty() + password: string; +} diff --git a/backend/src/modules/auth/services/AuthService.spec.ts b/backend/src/modules/auth/services/AuthService.spec.ts new file mode 100644 index 0000000..507bb94 --- /dev/null +++ b/backend/src/modules/auth/services/AuthService.spec.ts @@ -0,0 +1,47 @@ +import { UnauthorizedException } from '@nestjs/common'; +import type { ConfigService } from '@nestjs/config'; +import { AuthService } from './AuthService'; + +jest.mock('jsonwebtoken', () => ({ + sign: jest.fn(() => 'signed-token') +})); + +describe('AuthService', () => { + let service: AuthService; + let configService: { + get: jest.Mock; + }; + + beforeEach(() => { + configService = { + get: jest.fn((key: string) => { + if (key === 'app') { + return { cmsPassword: 'secret-password' }; + } + + if (key === 'jwt') { + return { secret: 'jwt-secret', expiresInMs: 3_600_000 }; + } + + return undefined; + }) + }; + + service = new AuthService(configService as unknown as ConfigService); + }); + + it('rejects invalid passwords', () => { + expect(() => service.verifyPassword('wrong')).toThrow(new UnauthorizedException('Invalid credentials')); + }); + + it('accepts the configured cms password', () => { + expect(() => service.verifyPassword('secret-password')).not.toThrow(); + }); + + it('returns a bearer cookie and expiry when login succeeds', () => { + const result = service.login('secret-password'); + + expect(result.bearerCookie).toBe('Bearer signed-token'); + expect(result.cookieExpires).toBeInstanceOf(Date); + }); +}); diff --git a/backend/src/modules/auth/services/AuthService.ts b/backend/src/modules/auth/services/AuthService.ts new file mode 100644 index 0000000..cc4642a --- /dev/null +++ b/backend/src/modules/auth/services/AuthService.ts @@ -0,0 +1,31 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import dayjs from '../../../plugins/dayjs'; +import * as jwt from 'jsonwebtoken'; +import { Config } from '../../../types/Config'; + +@Injectable() +export class AuthService { + constructor(private readonly configService: ConfigService) {} + + verifyPassword(password: string): void { + const { cmsPassword } = this.configService.get('app') as Config['app']; + + if (password !== cmsPassword) { + throw new UnauthorizedException('Invalid credentials'); + } + } + + login(password: string): { bearerCookie: string; cookieExpires: Date } { + this.verifyPassword(password); + + const { secret, expiresInMs } = this.configService.get('jwt') as Config['jwt']; + + const token = jwt.sign({}, secret, { expiresIn: Math.floor(expiresInMs / 1000) }); + + return { + bearerCookie: `Bearer ${token}`, + cookieExpires: dayjs().add(expiresInMs, 'millisecond').toDate() + }; + } +} diff --git a/backend/src/modules/dataWipe/DataWipeModule.ts b/backend/src/modules/dataWipe/DataWipeModule.ts new file mode 100644 index 0000000..69e93ab --- /dev/null +++ b/backend/src/modules/dataWipe/DataWipeModule.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { OrderModule } from '../order/OrderModule'; +import { PaymentModule } from '../payment/PaymentModule'; +import { StorefrontCheckoutModule } from '../storefrontCheckout/StorefrontCheckoutModule'; +import { OrderDataWipeService } from './services/OrderDataWipeService'; + +@Module({ + imports: [OrderModule, PaymentModule, StorefrontCheckoutModule], + providers: [OrderDataWipeService] +}) +export class DataWipeModule {} diff --git a/backend/src/modules/dataWipe/services/OrderDataWipeService.spec.ts b/backend/src/modules/dataWipe/services/OrderDataWipeService.spec.ts new file mode 100644 index 0000000..877dafc --- /dev/null +++ b/backend/src/modules/dataWipe/services/OrderDataWipeService.spec.ts @@ -0,0 +1,181 @@ +import { ConfigService } from '@nestjs/config'; +import type { DataSource, EntityManager, Repository } from 'typeorm'; +import { Order } from '../../order/entities/Order'; +import { Invoice } from '../../payment/entities/Invoice'; +import { CheckoutSession } from '../../storefrontCheckout/entities/CheckoutSession'; +import { OrderDataWipeService } from './OrderDataWipeService'; + +describe('OrderDataWipeService', () => { + let orderRepo: { + createQueryBuilder: jest.Mock; + }; + let dataSource: { + transaction: jest.Mock; + }; + let configService: { + get: jest.Mock; + }; + let service: OrderDataWipeService; + let queryBuilder: { + select: jest.Mock; + addSelect: jest.Mock; + where: jest.Mock; + andWhere: jest.Mock; + orderBy: jest.Mock; + limit: jest.Mock; + getRawMany: jest.Mock; + }; + let transactionManager: { + getRepository: jest.Mock; + }; + let transactionalOrderRepo: { delete: jest.Mock }; + let transactionalSessionRepo: { delete: jest.Mock }; + let transactionalInvoiceRepo: { delete: jest.Mock }; + + beforeEach(() => { + queryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + getRawMany: jest.fn() + }; + + orderRepo = { + createQueryBuilder: jest.fn().mockReturnValue(queryBuilder) + }; + + transactionalOrderRepo = { delete: jest.fn().mockResolvedValue(undefined) }; + transactionalSessionRepo = { delete: jest.fn().mockResolvedValue(undefined) }; + transactionalInvoiceRepo = { delete: jest.fn().mockResolvedValue(undefined) }; + + transactionManager = { + getRepository: jest.fn((entity: unknown) => { + if (entity === Order) { + return transactionalOrderRepo; + } + + if (entity === CheckoutSession) { + return transactionalSessionRepo; + } + + if (entity === Invoice) { + return transactionalInvoiceRepo; + } + + throw new Error(`Unexpected entity: ${String(entity)}`); + }) + }; + + dataSource = { + transaction: jest.fn(async (callback: (manager: EntityManager) => Promise) => { + await callback(transactionManager as unknown as EntityManager); + }) + }; + + configService = { + get: jest.fn((key: string) => { + if (key === 'order') { + return { dataRetentionDays: 30 }; + } + + return {}; + }) + }; + + service = new OrderDataWipeService( + orderRepo as unknown as Repository, + dataSource as unknown as DataSource, + configService as unknown as ConfigService + ); + }); + + it('computes retention cutoff from configured days', () => { + const before = Date.now(); + + const cutoff = service.getRetentionCutoff(); + + const after = Date.now(); + const expectedMs = 30 * 24 * 60 * 60 * 1000; + + expect(cutoff.getTime()).toBeGreaterThanOrEqual(before - expectedMs - 1000); + expect(cutoff.getTime()).toBeLessThanOrEqual(after - expectedMs + 1000); + }); + + it('finds expired orders with linked session and invoice ids', async () => { + queryBuilder.getRawMany.mockResolvedValue([ + { + id: 'order-1', + checkoutSessionId: 'session-1', + checkoutInvoiceId: 'invoice-1', + shippingInvoiceId: 'invoice-2' + } + ]); + + const cutoff = new Date('2026-01-01T00:00:00.000Z'); + jest.spyOn(service, 'getRetentionCutoff').mockReturnValue(cutoff); + + const targets = await service.findExpiredOrderWipeTargets(); + + expect(queryBuilder.where).toHaveBeenCalledWith('order.createdAt < :cutoff', { cutoff }); + expect(queryBuilder.andWhere).toHaveBeenCalledWith('order.checkoutSessionId IS NOT NULL'); + expect(queryBuilder.andWhere).toHaveBeenCalledWith('order.checkoutInvoiceId IS NOT NULL'); + expect(queryBuilder.limit).toHaveBeenCalledWith(50); + expect(targets).toEqual([ + { + id: 'order-1', + checkoutSessionId: 'session-1', + checkoutInvoiceId: 'invoice-1', + shippingInvoiceId: 'invoice-2' + } + ]); + }); + + it('deletes order, checkout session, and invoices in one transaction', async () => { + await service.wipeOrder({ + id: 'order-1', + checkoutSessionId: 'session-1', + checkoutInvoiceId: 'invoice-1', + shippingInvoiceId: 'invoice-2' + }); + + expect(dataSource.transaction).toHaveBeenCalledTimes(1); + expect(transactionalOrderRepo.delete).toHaveBeenCalledWith('order-1'); + expect(transactionalSessionRepo.delete).toHaveBeenCalledWith('session-1'); + expect(transactionalInvoiceRepo.delete).toHaveBeenCalledWith(['invoice-1', 'invoice-2']); + }); + + it('skips shipping invoice delete when absent', async () => { + await service.wipeOrder({ + id: 'order-1', + checkoutSessionId: 'session-1', + checkoutInvoiceId: 'invoice-1', + shippingInvoiceId: null + }); + + expect(transactionalInvoiceRepo.delete).toHaveBeenCalledWith(['invoice-1']); + }); + + it('stops after max iterations when orders keep failing to delete', async () => { + const target = { + id: 'order-1', + checkoutSessionId: 'session-1', + checkoutInvoiceId: 'invoice-1', + shippingInvoiceId: null + }; + + jest.spyOn(service, 'findExpiredOrderWipeTargets').mockResolvedValue([target]); + jest.spyOn(service, 'wipeOrder').mockRejectedValue(new Error('delete failed')); + const warnSpy = jest.spyOn(service['logger'], 'warn').mockImplementation(); + jest.spyOn(service['logger'], 'error').mockImplementation(); + + await service.wipeExpiredOrders(); + + expect(service.wipeOrder).toHaveBeenCalledTimes(20); + expect(warnSpy).toHaveBeenCalledWith( + 'Order wipe max iterations limit reached. Most likely some order keeps failing to be deleted or there are huge amount of orders to wipe.' + ); + }); +}); diff --git a/backend/src/modules/dataWipe/services/OrderDataWipeService.ts b/backend/src/modules/dataWipe/services/OrderDataWipeService.ts new file mode 100644 index 0000000..0c63c4c --- /dev/null +++ b/backend/src/modules/dataWipe/services/OrderDataWipeService.ts @@ -0,0 +1,99 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import dayjs from '../../../plugins/dayjs'; +import type { Config } from '../../../types/Config'; +import { getErrorMessage } from '../../../utils/getErrorMessage'; +import { Order } from '../../order/entities/Order'; +import { Invoice } from '../../payment/entities/Invoice'; +import { CheckoutSession } from '../../storefrontCheckout/entities/CheckoutSession'; +import type { OrderWipeTarget } from '../types/OrderWipeTarget'; + +@Injectable() +export class OrderDataWipeService { + private readonly logger = new Logger(OrderDataWipeService.name); + + private readonly wipeBatchSize = 50; + private readonly maxWipeIterations = 20; + + constructor( + @InjectRepository(Order) + private readonly orderRepo: Repository, + private readonly dataSource: DataSource, + private readonly configService: ConfigService + ) {} + + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async wipeExpiredOrders(): Promise { + let wipedCount = 0; + let iterations = 0; + + let targets = await this.findExpiredOrderWipeTargets(); + + while (targets.length > 0 && iterations < this.maxWipeIterations) { + for (const target of targets) { + try { + await this.wipeOrder(target); + + wipedCount += 1; + } catch (error) { + this.logger.error(`Failed to wipe order ${target.id}: ${getErrorMessage(error)}`); + } + } + + targets = await this.findExpiredOrderWipeTargets(); + iterations += 1; + } + + if (iterations >= this.maxWipeIterations && targets.length > 0) { + this.logger.warn( + 'Order wipe max iterations limit reached. Most likely some order keeps failing to be deleted or there are huge amount of orders to wipe.' + ); + } + + if (wipedCount > 0) { + this.logger.log(`Wiped ${wipedCount} expired order(s).`); + } + } + + async findExpiredOrderWipeTargets(): Promise { + const cutoff = this.getRetentionCutoff(); + + return this.orderRepo + .createQueryBuilder('order') + .select('order.id', 'id') + .addSelect('order.checkoutSessionId', 'checkoutSessionId') + .addSelect('order.checkoutInvoiceId', 'checkoutInvoiceId') + .addSelect('order.shippingInvoiceId', 'shippingInvoiceId') + .where('order.createdAt < :cutoff', { cutoff }) + .andWhere('order.checkoutSessionId IS NOT NULL') + .andWhere('order.checkoutInvoiceId IS NOT NULL') + .orderBy('order.createdAt', 'ASC') + .limit(this.wipeBatchSize) + .getRawMany(); + } + + async wipeOrder({ id, checkoutSessionId, checkoutInvoiceId, shippingInvoiceId }: OrderWipeTarget): Promise { + const invoiceIds = [checkoutInvoiceId, shippingInvoiceId].filter((invoiceId): invoiceId is string => + Boolean(invoiceId) + ); + + await this.dataSource.transaction(async manager => { + const orderRepo = manager.getRepository(Order); + const sessionRepo = manager.getRepository(CheckoutSession); + const invoiceRepo = manager.getRepository(Invoice); + + await orderRepo.delete(id); + await sessionRepo.delete(checkoutSessionId); + await invoiceRepo.delete(invoiceIds); + }); + } + + getRetentionCutoff(): Date { + const { dataRetentionDays } = this.configService.get('order') as Config['order']; + + return dayjs().subtract(dataRetentionDays, 'day').toDate(); + } +} diff --git a/backend/src/modules/dataWipe/types/OrderWipeTarget.ts b/backend/src/modules/dataWipe/types/OrderWipeTarget.ts new file mode 100644 index 0000000..d0132ef --- /dev/null +++ b/backend/src/modules/dataWipe/types/OrderWipeTarget.ts @@ -0,0 +1,6 @@ +export type OrderWipeTarget = { + id: string; + checkoutSessionId: string; + checkoutInvoiceId: string; + shippingInvoiceId: string | null; +}; diff --git a/backend/src/modules/discountCode/DiscountCodesModule.ts b/backend/src/modules/discountCode/DiscountCodesModule.ts new file mode 100644 index 0000000..d1a6c9e --- /dev/null +++ b/backend/src/modules/discountCode/DiscountCodesModule.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ProductsModule } from '../product/ProductsModule'; +import { DiscountCodesController } from './controllers/DiscountCodesController'; +import { DiscountCode } from './entities/DiscountCode'; +import { DiscountCodesService } from './services/DiscountCodesService'; + +@Module({ + imports: [TypeOrmModule.forFeature([DiscountCode]), ProductsModule], + controllers: [DiscountCodesController], + providers: [DiscountCodesService], + exports: [DiscountCodesService] +}) +export class DiscountCodesModule {} diff --git a/backend/src/modules/discountCode/controllers/DiscountCodesController.ts b/backend/src/modules/discountCode/controllers/DiscountCodesController.ts new file mode 100644 index 0000000..cd9d5e7 --- /dev/null +++ b/backend/src/modules/discountCode/controllers/DiscountCodesController.ts @@ -0,0 +1,48 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + UseGuards +} from '@nestjs/common'; +import { JwtGuard } from '../../../guards/JwtGuard'; +import { CreateOrUpdateDiscountCodeDto } from '../dto/CreateOrUpdateDiscountCodeDto'; +import { DiscountCodesService } from '../services/DiscountCodesService'; + +@Controller('discount-codes') +@UseGuards(JwtGuard) +export class DiscountCodesController { + constructor(private readonly discountCodesService: DiscountCodesService) {} + + @Get('/') + findAll() { + return this.discountCodesService.findAll(); + } + + @Get('/:id') + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.discountCodesService.findOne(id); + } + + @Post('/') + create(@Body() dto: CreateOrUpdateDiscountCodeDto) { + return this.discountCodesService.create(dto); + } + + @Patch('/:id') + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: CreateOrUpdateDiscountCodeDto) { + return this.discountCodesService.update(id, dto); + } + + @Delete('/:id') + @HttpCode(HttpStatus.NO_CONTENT) + async remove(@Param('id', ParseUUIDPipe) id: string) { + await this.discountCodesService.remove(id); + } +} diff --git a/backend/src/modules/discountCode/dto/CreateOrUpdateDiscountCodeDto.ts b/backend/src/modules/discountCode/dto/CreateOrUpdateDiscountCodeDto.ts new file mode 100644 index 0000000..34e7c0d --- /dev/null +++ b/backend/src/modules/discountCode/dto/CreateOrUpdateDiscountCodeDto.ts @@ -0,0 +1,56 @@ +import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { getAppConfig } from '../../../config'; +import { NullOrDate, NullOrInt, NullOrNumber } from '../../../validation/decorators/nullOr'; +import { DiscountType } from '../types/DiscountType'; + +const { + validation: { discountCodeMaxLength } +} = getAppConfig(); + +export class CreateOrUpdateDiscountCodeDto { + @IsNotEmpty() + @IsString() + @MaxLength(discountCodeMaxLength) + code: string; + + @IsNotEmpty() + @IsEnum(DiscountType) + type: DiscountType; + + @IsNotEmpty() + @IsNumber() + @Min(0) + value: number; + + @IsNotEmpty() + @IsBoolean() + isActive: boolean; + + @NullOrDate() + validFrom: Date | null; + + @NullOrDate() + validUntil: Date | null; + + @NullOrInt({ min: 1 }) + maxRedemptions: number | null; + + @NullOrNumber({ min: 0 }) + minOrderAmount: number | null; + + @IsNotEmpty() + @IsBoolean() + isExclusive: boolean; + + @IsArray() + @IsUUID('4', { each: true }) + productIds: string[]; + + @IsArray() + @IsUUID('4', { each: true }) + categoryIds: string[]; + + @IsArray() + @IsUUID('4', { each: true }) + variantIds: string[]; +} diff --git a/backend/src/modules/discountCode/entities/DiscountCode.ts b/backend/src/modules/discountCode/entities/DiscountCode.ts new file mode 100644 index 0000000..64ff083 --- /dev/null +++ b/backend/src/modules/discountCode/entities/DiscountCode.ts @@ -0,0 +1,92 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinTable, + ManyToMany, + PrimaryGeneratedColumn, + UpdateDateColumn +} from 'typeorm'; +import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer'; +import { Category } from '../../product/entities/Category'; +import { Product } from '../../product/entities/Product'; +import { ProductVariant } from '../../product/entities/ProductVariant'; +import { DiscountType } from '../types/DiscountType'; + +@Entity('discount_codes') +export class DiscountCode { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + code: string; + + @Column({ type: 'enum', enum: DiscountType }) + type: DiscountType; + + @Column({ + type: 'numeric', + precision: 12, + scale: 2, + transformer: new ColumnNumericTransformer() + }) + value: number; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'timestamptz', nullable: true, default: null }) + validFrom: Date | null; + + @Column({ type: 'timestamptz', nullable: true, default: null }) + validUntil: Date | null; + + @Column({ type: 'integer', nullable: true, default: null }) + maxRedemptions: number | null; + + @Column({ type: 'integer', default: 0 }) + redemptionCount: number; + + @Column({ + type: 'numeric', + precision: 12, + scale: 2, + nullable: true, + default: null, + transformer: new ColumnNumericTransformer() + }) + minOrderAmount: number | null; + + @Column({ type: 'boolean', default: false }) + isExclusive: boolean; + + @ManyToMany(() => Product) + @JoinTable({ + name: 'discount_codes_products', + joinColumn: { name: 'discountCodeId', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'productId', referencedColumnName: 'id' } + }) + products: Product[]; + + @ManyToMany(() => Category) + @JoinTable({ + name: 'discount_codes_categories', + joinColumn: { name: 'discountCodeId', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'categoryId', referencedColumnName: 'id' } + }) + categories: Category[]; + + @ManyToMany(() => ProductVariant) + @JoinTable({ + name: 'discount_codes_variants', + joinColumn: { name: 'discountCodeId', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'variantId', referencedColumnName: 'id' } + }) + variants: ProductVariant[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/modules/discountCode/services/DiscountCodesService.spec.ts b/backend/src/modules/discountCode/services/DiscountCodesService.spec.ts new file mode 100644 index 0000000..503a6e3 --- /dev/null +++ b/backend/src/modules/discountCode/services/DiscountCodesService.spec.ts @@ -0,0 +1,192 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import type { Repository } from 'typeorm'; +import type { CategoriesService } from '../../product/services/CategoriesService'; +import type { ProductVariantsService } from '../../product/services/ProductVariantsService'; +import type { ProductsService } from '../../product/services/ProductsService'; +import { DiscountCode } from '../entities/DiscountCode'; +import { DiscountType } from '../types/DiscountType'; +import { DiscountCodesService } from './DiscountCodesService'; + +const buildDto = () => ({ + code: ' save10 ', + type: DiscountType.Percent, + value: 10, + isActive: true, + validFrom: null, + validUntil: null, + maxRedemptions: null, + minOrderAmount: null, + isExclusive: false, + productIds: [] as string[], + categoryIds: [] as string[], + variantIds: [] as string[] +}); + +describe('DiscountCodesService', () => { + let service: DiscountCodesService; + let discountCodeRepo: { + find: jest.Mock; + findOne: jest.Mock; + create: jest.Mock; + save: jest.Mock; + exists: jest.Mock; + delete: jest.Mock; + }; + let productsService: { + findByIds: jest.Mock; + }; + let categoriesService: { + findByIds: jest.Mock; + }; + let productVariantsService: { + findByIds: jest.Mock; + }; + + beforeEach(() => { + discountCodeRepo = { + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn(data => ({ id: 'discount-1', ...data })), + save: jest.fn(async (entity: DiscountCode) => entity), + exists: jest.fn().mockResolvedValue(false), + delete: jest.fn().mockResolvedValue(undefined) + }; + + productsService = { + findByIds: jest.fn().mockResolvedValue([]) + }; + + categoriesService = { + findByIds: jest.fn().mockResolvedValue([]) + }; + + productVariantsService = { + findByIds: jest.fn().mockResolvedValue([]) + }; + + service = new DiscountCodesService( + discountCodeRepo as unknown as Repository, + productsService as unknown as ProductsService, + productVariantsService as unknown as ProductVariantsService, + categoriesService as unknown as CategoriesService + ); + }); + + it('normalizes discount codes by trimming and uppercasing', () => { + expect(service.normalizeCode(' save10 ')).toBe('SAVE10'); + }); + + it('returns an empty list when no normalized codes are provided', async () => { + await expect(service.findByNormalizedCodes([])).resolves.toEqual([]); + expect(discountCodeRepo.find).not.toHaveBeenCalled(); + }); + + it('deduplicates normalized codes before loading entities', async () => { + await service.findByNormalizedCodes(['save10', 'SAVE10', ' save10 ']); + + expect(discountCodeRepo.find).toHaveBeenCalledWith({ + where: { code: expect.anything() }, + relations: ['products', 'categories', 'variants'] + }); + const whereArg = discountCodeRepo.find.mock.calls[0][0].where.code; + expect(whereArg._value).toEqual(['SAVE10']); + }); + + it('rejects duplicate codes on create', async () => { + discountCodeRepo.findOne.mockResolvedValue({ id: 'existing', code: 'SAVE10' }); + + await expect(service.create(buildDto())).rejects.toThrow( + new BadRequestException('Discount code already exists') + ); + }); + + it('rejects percent values outside 0-100', async () => { + await expect(service.create({ ...buildDto(), value: 101 })).rejects.toThrow( + new BadRequestException('Percent value must be between 0 and 100') + ); + + await expect(service.create({ ...buildDto(), value: -1 })).rejects.toThrow( + new BadRequestException('Percent value must be between 0 and 100') + ); + }); + + it('rejects negative fixed discount values', async () => { + await expect( + service.create({ + ...buildDto(), + type: DiscountType.Fixed, + value: -5 + }) + ).rejects.toThrow(new BadRequestException('Fixed value must be at least 0')); + }); + + it('allows updating a code without treating itself as a duplicate', async () => { + const existing = { id: 'discount-1', code: 'SAVE10' } as DiscountCode; + + discountCodeRepo.findOne + .mockResolvedValueOnce(existing) + .mockResolvedValueOnce(existing) + .mockResolvedValueOnce(existing); + + await service.update('discount-1', buildDto()); + + expect(discountCodeRepo.save).toHaveBeenCalled(); + }); + + it('rejects updating to a code owned by another discount', async () => { + discountCodeRepo.findOne + .mockResolvedValueOnce({ id: 'discount-1', code: 'OLD' } as DiscountCode) + .mockResolvedValueOnce({ id: 'discount-2', code: 'SAVE10' }); + + await expect(service.update('discount-1', buildDto())).rejects.toThrow( + new BadRequestException('Discount code already exists') + ); + }); + + it('rejects discount date ranges where validFrom is after validUntil', async () => { + await expect( + service.create({ + ...buildDto(), + validFrom: new Date('2026-02-01T00:00:00.000Z'), + validUntil: new Date('2026-01-01T00:00:00.000Z') + }) + ).rejects.toThrow(new BadRequestException('Date from should be before date until')); + }); + + it('creates a normalized discount code', async () => { + discountCodeRepo.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 'discount-1', code: 'SAVE10' } as DiscountCode); + + const created = await service.create(buildDto()); + + expect(discountCodeRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'SAVE10', + type: DiscountType.Percent, + value: 10 + }) + ); + expect(discountCodeRepo.save).toHaveBeenCalled(); + expect(created).toEqual(expect.objectContaining({ id: 'discount-1', code: 'SAVE10' })); + }); + + it('throws when loading a missing discount code by id', async () => { + await expect(service.findOne('missing-id')).rejects.toThrow(NotFoundException); + }); + + it('deletes an existing discount code', async () => { + discountCodeRepo.exists.mockResolvedValue(true); + + await service.remove('discount-1'); + + expect(discountCodeRepo.delete).toHaveBeenCalledWith('discount-1'); + }); + + it('throws when deleting a missing discount code', async () => { + discountCodeRepo.exists.mockResolvedValue(false); + + await expect(service.remove('missing-id')).rejects.toThrow(NotFoundException); + expect(discountCodeRepo.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/modules/discountCode/services/DiscountCodesService.ts b/backend/src/modules/discountCode/services/DiscountCodesService.ts new file mode 100644 index 0000000..6e8c7e2 --- /dev/null +++ b/backend/src/modules/discountCode/services/DiscountCodesService.ts @@ -0,0 +1,193 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import dayjs from '../../../plugins/dayjs'; +import { In, Repository } from 'typeorm'; +import { CategoriesService } from '../../product/services/CategoriesService'; +import { ProductVariantsService } from '../../product/services/ProductVariantsService'; +import { ProductsService } from '../../product/services/ProductsService'; +import type { CreateOrUpdateDiscountCodeDto } from '../dto/CreateOrUpdateDiscountCodeDto'; +import { DiscountCode } from '../entities/DiscountCode'; +import { DiscountType } from '../types/DiscountType'; + +@Injectable() +export class DiscountCodesService { + constructor( + @InjectRepository(DiscountCode) + private readonly discountCodeRepo: Repository, + private readonly productsService: ProductsService, + private readonly productVariantsService: ProductVariantsService, + private readonly categoriesService: CategoriesService + ) {} + + normalizeCode(raw: string): string { + return raw.trim().toUpperCase(); + } + + async findAll(): Promise { + return this.discountCodeRepo.find({ + order: { createdAt: 'DESC' }, + relations: ['products', 'categories', 'variants', 'variants.product'] + }); + } + + async findOne(id: string): Promise { + const entity = await this.discountCodeRepo.findOne({ + where: { id }, + relations: ['products', 'categories', 'variants', 'variants.product'] + }); + + if (!entity) { + throw new NotFoundException(); + } + + return entity; + } + + async findByNormalizedCodes(codes: string[]): Promise { + if (codes.length === 0) { + return []; + } + + const normalized = [...new Set(codes.map(c => this.normalizeCode(c)))]; + + return this.discountCodeRepo.find({ + where: { code: In(normalized) }, + relations: ['products', 'categories', 'variants'] + }); + } + + async create({ + code, + type, + value, + isActive, + validFrom, + validUntil, + maxRedemptions, + minOrderAmount, + isExclusive, + productIds, + categoryIds, + variantIds + }: CreateOrUpdateDiscountCodeDto): Promise { + const normalizedCode = this.normalizeCode(code); + + await this.validateCodeAvailable(normalizedCode); + + this.validateDiscountDateRange(validFrom, validUntil); + this.validateDiscountValueForType(type, value); + + const [products, categories, variants] = await Promise.all([ + this.productsService.findByIds(productIds), + this.categoriesService.findByIds(categoryIds), + this.productVariantsService.findByIds(variantIds) + ]); + + const entity = this.discountCodeRepo.create({ + code: normalizedCode, + type, + value, + isActive, + validFrom, + validUntil, + maxRedemptions, + minOrderAmount, + isExclusive, + products, + categories, + variants + }); + + await this.discountCodeRepo.save(entity); + + return this.findOne(entity.id); + } + + async update( + id: string, + { + code, + type, + value, + isActive, + validFrom, + validUntil, + maxRedemptions, + minOrderAmount, + isExclusive, + productIds, + categoryIds, + variantIds + }: CreateOrUpdateDiscountCodeDto + ): Promise { + const entity = await this.discountCodeRepo.findOne({ where: { id } }); + + if (!entity) { + throw new NotFoundException(); + } + + const normalizedCode = this.normalizeCode(code); + + await this.validateCodeAvailable(normalizedCode, id); + + this.validateDiscountDateRange(validFrom, validUntil); + this.validateDiscountValueForType(type, value); + + const [products, categories, variants] = await Promise.all([ + this.productsService.findByIds(productIds), + this.categoriesService.findByIds(categoryIds), + this.productVariantsService.findByIds(variantIds) + ]); + + entity.code = normalizedCode; + entity.type = type; + entity.value = value; + entity.isActive = isActive; + entity.validFrom = validFrom; + entity.validUntil = validUntil; + entity.maxRedemptions = maxRedemptions; + entity.minOrderAmount = minOrderAmount; + entity.isExclusive = isExclusive; + entity.products = products; + entity.categories = categories; + entity.variants = variants; + + await this.discountCodeRepo.save(entity); + + return this.findOne(id); + } + + async remove(id: string): Promise { + const exists = await this.discountCodeRepo.exists({ where: { id } }); + + if (!exists) { + throw new NotFoundException(); + } + + await this.discountCodeRepo.delete(id); + } + + private async validateCodeAvailable(code: string, excludeId?: string): Promise { + const existing = await this.discountCodeRepo.findOne({ where: { code } }); + + if (existing && existing.id !== excludeId) { + throw new BadRequestException('Discount code already exists'); + } + } + + private validateDiscountDateRange(validFrom: Date | null, validUntil: Date | null): void { + if (validFrom && validUntil && dayjs(validFrom).isAfter(dayjs(validUntil))) { + throw new BadRequestException('Date from should be before date until'); + } + } + + private validateDiscountValueForType(type: DiscountType, value: number): void { + if (type === DiscountType.Percent && (value < 0 || value > 100)) { + throw new BadRequestException('Percent value must be between 0 and 100'); + } + + if (type === DiscountType.Fixed && value < 0) { + throw new BadRequestException('Fixed value must be at least 0'); + } + } +} diff --git a/backend/src/modules/discountCode/types/DiscountType.ts b/backend/src/modules/discountCode/types/DiscountType.ts new file mode 100644 index 0000000..111f2f7 --- /dev/null +++ b/backend/src/modules/discountCode/types/DiscountType.ts @@ -0,0 +1,4 @@ +export enum DiscountType { + Percent = 'percent', + Fixed = 'fixed' +} diff --git a/backend/src/modules/encryption/EncryptionModule.ts b/backend/src/modules/encryption/EncryptionModule.ts new file mode 100644 index 0000000..fbc6ca5 --- /dev/null +++ b/backend/src/modules/encryption/EncryptionModule.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { EncryptionService } from './services/EncryptionService'; + +@Module({ + providers: [EncryptionService], + exports: [EncryptionService] +}) +export class EncryptionModule {} diff --git a/backend/src/modules/encryption/services/EncryptionService.spec.ts b/backend/src/modules/encryption/services/EncryptionService.spec.ts new file mode 100644 index 0000000..01da9ed --- /dev/null +++ b/backend/src/modules/encryption/services/EncryptionService.spec.ts @@ -0,0 +1,113 @@ +import { ConfigService } from '@nestjs/config'; +import { randomBytes } from 'node:crypto'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { EncryptedField } from '../types/EncryptedField'; +import { EncryptionService } from './EncryptionService'; + +describe('EncryptionService', () => { + const validKeyBase64 = randomBytes(32).toString('base64'); + let service: EncryptionService; + let configGet: jest.MockedFunction; + + beforeEach(() => { + configGet = jest.fn().mockReturnValue({ keyBase64: validKeyBase64 }) as jest.MockedFunction< + ConfigService['get'] + >; + service = new EncryptionService({ get: configGet } as unknown as ConfigService); + }); + + const parseSerialized = (serialized: string): EncryptedField => JSON.parse(serialized) as EncryptedField; + + it('reads encryption config when encrypting plaintext', () => { + service.encryptPlaintext('hello'); + + expect(configGet).toHaveBeenCalledWith('encryption'); + }); + + it('round-trips plaintext', () => { + const plaintext = 'Deliver via Simplex: example-handle'; + const serialized = service.encryptPlaintext(plaintext); + + expect(service.decryptPlaintext(serialized)).toBe(plaintext); + }); + + it('throws when encryption key is not configured', () => { + configGet.mockReturnValue({ keyBase64: '' }); + + expect(() => service.encryptPlaintext('x')).toThrow('Encryption key is not configured'); + }); + + it('throws when encryption key is not 32 bytes', () => { + configGet.mockReturnValue({ keyBase64: Buffer.from('short').toString('base64') }); + + expect(() => service.encryptPlaintext('x')).toThrow('Encryption key must be a base64-encoded 32-byte value'); + }); + + it('throws on invalid serialized payload', () => { + expect(() => service.decryptPlaintext('{"ciphertext":"x"}')).toThrow('Invalid encrypted field payload'); + }); + + it('fails decrypt when ciphertext is tampered', () => { + const serialized = service.encryptPlaintext('secret'); + const encrypted = parseSerialized(serialized); + + encrypted.ciphertext = Buffer.from('tampered').toString('base64'); + + expect(() => service.decryptPlaintext(JSON.stringify(encrypted))).toThrow(); + }); + + it('throws when encryption key is not configured on decrypt', () => { + const serialized = service.encryptPlaintext('secret'); + configGet.mockReturnValue({ keyBase64: '' }); + + expect(() => service.decryptPlaintext(serialized)).toThrow('Encryption key is not configured'); + }); + + it('fails decrypt when auth tag is tampered', () => { + const serialized = service.encryptPlaintext('secret'); + const encrypted = parseSerialized(serialized); + + encrypted.tag = Buffer.from('tampered-tag').toString('base64'); + + expect(() => service.decryptPlaintext(JSON.stringify(encrypted))).toThrow(); + }); + + it('fails decrypt when encryption key changes', () => { + const serialized = service.encryptPlaintext('secret'); + configGet.mockReturnValue({ keyBase64: randomBytes(32).toString('base64') }); + + expect(() => service.decryptPlaintext(serialized)).toThrow(); + }); + + it('decryptPlaintextFieldInPlace decrypts each item field', () => { + const messages = [{ body: service.encryptPlaintext('Hello') }, { body: service.encryptPlaintext('World') }]; + + service.decryptPlaintextFieldInPlace(messages, 'body'); + + expect(messages).toEqual([{ body: 'Hello' }, { body: 'World' }]); + }); + + it('decryptPlaintextFieldInPlace handles undefined items', () => { + expect(() => service.decryptPlaintextFieldInPlace(undefined, 'body')).not.toThrow(); + }); + + it('round-trips files written as encrypted buffer', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'encryption-service-')); + const filePath = join(tempDir, 'attachment.bin'); + const plaintext = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d, 0x00, 0xff]); + + try { + await service.writeEncryptedBufferToPath(plaintext, filePath); + + const onDisk = await readFile(filePath, 'utf8'); + + expect(onDisk).not.toEqual(plaintext.toString()); + + expect(await service.decryptFileAtPath(filePath)).toEqual(plaintext); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }, 15_000); +}); diff --git a/backend/src/modules/encryption/services/EncryptionService.ts b/backend/src/modules/encryption/services/EncryptionService.ts new file mode 100644 index 0000000..a9905ce --- /dev/null +++ b/backend/src/modules/encryption/services/EncryptionService.ts @@ -0,0 +1,137 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { Config } from '../../../types/Config'; +import { EncryptedField } from '../types/EncryptedField'; + +@Injectable() +export class EncryptionService { + private readonly algorithm = 'aes-256-gcm'; + private readonly ivLengthBytes = 12; + private readonly encryptionKeyByteLength = 32; + + constructor(private readonly configService: ConfigService) {} + + encryptPlaintext(plaintext: string): string { + const encrypted = this.encryptBuffer(Buffer.from(plaintext, 'utf8')); + + return this.serialize(encrypted); + } + + decryptPlaintext(serialized: string): string { + const encrypted = this.deserialize(serialized); + + return this.decryptBuffer(encrypted).toString('utf8'); + } + + decryptPlaintextFieldInPlace(items: T[] | undefined, field: keyof T): void { + this.decryptPlaintextInPlace( + items, + item => item[field] as string, + (item, plaintext) => { + (item as Record)[field] = plaintext; + } + ); + } + + async writeEncryptedBufferToPath(plaintext: Buffer, absolutePath: string): Promise { + const serialized = this.encryptBufferToSerialized(plaintext); + + await writeFile(absolutePath, serialized, 'utf8'); + } + + async decryptFileAtPath(absolutePath: string): Promise { + const serialized = await readFile(absolutePath, 'utf8'); + + return this.decryptBufferToSerialized(serialized); + } + + private decryptPlaintextInPlace( + items: T[] | undefined, + read: (item: T) => string, + write: (item: T, plaintext: string) => void + ): void { + if (!items) { + return; + } + + for (const item of items) { + write(item, this.decryptPlaintext(read(item))); + } + } + + private encryptBufferToSerialized(plaintext: Buffer): string { + const encrypted = this.encryptBuffer(plaintext); + + return this.serialize(encrypted); + } + + private decryptBufferToSerialized(serialized: string): Buffer { + const encrypted = this.deserialize(serialized); + + return this.decryptBuffer(encrypted); + } + + private encryptBuffer(plaintext: Buffer): EncryptedField { + const key = this.parseKey(); + const iv = randomBytes(this.ivLengthBytes); + const cipher = createCipheriv(this.algorithm, key, iv); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + + return { + ciphertext: ciphertext.toString('base64'), + iv: iv.toString('base64'), + tag: tag.toString('base64') + }; + } + + private decryptBuffer(payload: EncryptedField): Buffer { + const key = this.parseKey(); + const iv = Buffer.from(payload.iv, 'base64'); + const tag = Buffer.from(payload.tag, 'base64'); + const ciphertext = Buffer.from(payload.ciphertext, 'base64'); + const decipher = createDecipheriv(this.algorithm, key, iv); + + decipher.setAuthTag(tag); + + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); + } + + private serialize(payload: EncryptedField): string { + return JSON.stringify(payload); + } + + private deserialize(serialized: string): EncryptedField { + const parsed: unknown = JSON.parse(serialized); + + if ( + typeof parsed !== 'object' || + parsed === null || + typeof (parsed as EncryptedField).ciphertext !== 'string' || + typeof (parsed as EncryptedField).iv !== 'string' || + typeof (parsed as EncryptedField).tag !== 'string' + ) { + throw new Error('Invalid encrypted field payload'); + } + + return parsed as EncryptedField; + } + + private parseKey(): Buffer { + const { keyBase64 } = this.configService.get('encryption') as Config['encryption']; + + if (!keyBase64) { + throw new Error('Encryption key is not configured'); + } + + const key = Buffer.from(keyBase64, 'base64'); + + if (key.length !== this.encryptionKeyByteLength) { + throw new Error(`Encryption key must be a base64-encoded ${this.encryptionKeyByteLength}-byte value`); + } + + return key; + } +} diff --git a/backend/src/modules/encryption/types/EncryptedField.ts b/backend/src/modules/encryption/types/EncryptedField.ts new file mode 100644 index 0000000..4182dc9 --- /dev/null +++ b/backend/src/modules/encryption/types/EncryptedField.ts @@ -0,0 +1,5 @@ +export interface EncryptedField { + ciphertext: string; + iv: string; + tag: string; +} diff --git a/backend/src/modules/healthCheck/HealthCheckModule.ts b/backend/src/modules/healthCheck/HealthCheckModule.ts new file mode 100644 index 0000000..d9ea1bc --- /dev/null +++ b/backend/src/modules/healthCheck/HealthCheckModule.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthCheckController } from './controllers/HealthCheckController'; + +@Module({ + controllers: [HealthCheckController] +}) +export class HealthCheckModule {} diff --git a/backend/src/modules/healthCheck/controllers/HealthCheckController.ts b/backend/src/modules/healthCheck/controllers/HealthCheckController.ts new file mode 100644 index 0000000..2ab8ec6 --- /dev/null +++ b/backend/src/modules/healthCheck/controllers/HealthCheckController.ts @@ -0,0 +1,12 @@ +import { Controller, Get, HttpStatus, Res } from '@nestjs/common'; +import { SkipThrottle } from '@nestjs/throttler'; +import type { Response } from 'express'; + +@Controller('/health-check') +@SkipThrottle({ default: true }) +export class HealthCheckController { + @Get('/') + getStatus(@Res() res: Response) { + return res.sendStatus(HttpStatus.OK); + } +} diff --git a/backend/src/modules/moneroWallet/MoneroWalletModule.ts b/backend/src/modules/moneroWallet/MoneroWalletModule.ts new file mode 100644 index 0000000..2f6d2ba --- /dev/null +++ b/backend/src/modules/moneroWallet/MoneroWalletModule.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/AuthModule'; +import { MoneroWalletController } from './controllers/MoneroWalletController'; +import { MoneroWalletAdminService } from './services/MoneroWalletAdminService'; +import { MoneroWalletRpcClient } from './services/MoneroWalletRpcClient'; +import { MoneroWalletRpcConnectionService } from './services/MoneroWalletRpcConnectionService'; + +@Module({ + imports: [AuthModule], + controllers: [MoneroWalletController], + providers: [MoneroWalletRpcClient, MoneroWalletRpcConnectionService, MoneroWalletAdminService], + exports: [MoneroWalletRpcClient] +}) +export class MoneroWalletModule {} diff --git a/backend/src/modules/moneroWallet/controllers/MoneroWalletController.ts b/backend/src/modules/moneroWallet/controllers/MoneroWalletController.ts new file mode 100644 index 0000000..8202632 --- /dev/null +++ b/backend/src/modules/moneroWallet/controllers/MoneroWalletController.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import { JwtGuard } from '../../../guards/JwtGuard'; +import { throttleProfiles } from '../../../config/throttleProfiles'; +import { MoneroWalletRevealSeedDto } from '../dto/MoneroWalletRevealSeedDto'; +import { MoneroWalletWithdrawDto } from '../dto/MoneroWalletWithdrawDto'; +import { MoneroWalletAdminService } from '../services/MoneroWalletAdminService'; + +@Controller('monero-wallet') +@UseGuards(JwtGuard) +export class MoneroWalletController { + constructor(private readonly walletAdminService: MoneroWalletAdminService) {} + + @Get('/') + getStatus() { + return this.walletAdminService.getStatus(); + } + + @Post('/withdraw') + @Throttle(throttleProfiles.walletWithdraw) + withdraw(@Body() { destinationAddress, password }: MoneroWalletWithdrawDto) { + return this.walletAdminService.withdrawAll(destinationAddress, password); + } + + @Post('/reveal-seed') + @Throttle(throttleProfiles.walletRevealSeed) + revealSeed(@Body() { password }: MoneroWalletRevealSeedDto) { + return this.walletAdminService.revealSeed(password); + } +} diff --git a/backend/src/modules/moneroWallet/dto/MoneroWalletRevealSeedDto.ts b/backend/src/modules/moneroWallet/dto/MoneroWalletRevealSeedDto.ts new file mode 100644 index 0000000..98d615d --- /dev/null +++ b/backend/src/modules/moneroWallet/dto/MoneroWalletRevealSeedDto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class MoneroWalletRevealSeedDto { + @IsString() + @IsNotEmpty() + password: string; +} diff --git a/backend/src/modules/moneroWallet/dto/MoneroWalletWithdrawDto.ts b/backend/src/modules/moneroWallet/dto/MoneroWalletWithdrawDto.ts new file mode 100644 index 0000000..942c1c5 --- /dev/null +++ b/backend/src/modules/moneroWallet/dto/MoneroWalletWithdrawDto.ts @@ -0,0 +1,15 @@ +import { Transform } from 'class-transformer'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { IsMoneroStandardAddress } from '../../../validation/decorators/isMoneroStandardAddress'; + +export class MoneroWalletWithdrawDto { + @Transform(({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @IsNotEmpty() + @IsMoneroStandardAddress() + destinationAddress: string; + + @IsString() + @IsNotEmpty() + password: string; +} diff --git a/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.spec.ts b/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.spec.ts new file mode 100644 index 0000000..75e7378 --- /dev/null +++ b/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.spec.ts @@ -0,0 +1,172 @@ +import { BadRequestException, ServiceUnavailableException } from '@nestjs/common'; +import type { ConfigService } from '@nestjs/config'; +import axios from 'axios'; +import type { AuthService } from '../../auth/services/AuthService'; +import { MoneroWalletSyncStatus } from '../types/MoneroWalletSyncStatus'; +import type { MoneroWalletRpcClient } from './MoneroWalletRpcClient'; +import { MoneroWalletAdminService } from './MoneroWalletAdminService'; + +jest.mock('axios'); + +const mockedAxios = axios as jest.Mocked; + +describe('MoneroWalletAdminService', () => { + let service: MoneroWalletAdminService; + let walletRpcClient: { + tryRefresh: jest.Mock; + getVersion: jest.Mock; + getHeight: jest.Mock; + getBalance: jest.Mock; + sweepAll: jest.Mock; + queryMnemonic: jest.Mock; + }; + let authService: { + verifyPassword: jest.Mock; + }; + let configService: { + get: jest.Mock; + }; + + beforeEach(() => { + walletRpcClient = { + tryRefresh: jest.fn().mockResolvedValue(undefined), + getVersion: jest.fn().mockResolvedValue('0.18.3.1'), + getHeight: jest.fn().mockResolvedValue(3_000_000), + getBalance: jest.fn().mockResolvedValue({ + balanceAtomic: '2000000000000', + unlockedBalanceAtomic: '1000000000000' + }), + sweepAll: jest.fn().mockResolvedValue({ + txHashes: ['tx-hash-1'], + amountAtomic: '1000000000000' + }), + queryMnemonic: jest.fn().mockResolvedValue('seed words') + }; + + authService = { + verifyPassword: jest.fn() + }; + + configService = { + get: jest.fn().mockReturnValue({ + network: 'mainnet', + daemonRpcUrl: 'http://daemon.test/json_rpc', + rpcTimeoutMs: 5000 + }) + }; + + mockedAxios.post.mockResolvedValue({ + data: { result: { height: 3_000_000 } } + }); + + service = new MoneroWalletAdminService( + walletRpcClient as unknown as MoneroWalletRpcClient, + authService as unknown as AuthService, + configService as unknown as ConfigService + ); + }); + + it('returns wallet status when RPC and daemon calls succeed', async () => { + const status = await service.getStatus(); + + expect(walletRpcClient.tryRefresh).toHaveBeenCalled(); + expect(status).toEqual( + expect.objectContaining({ + network: 'mainnet', + rpcVersion: '0.18.3.1', + walletHeight: 3_000_000, + daemonHeight: 3_000_000, + syncStatus: MoneroWalletSyncStatus.Synced, + balanceXmr: '2.00000000', + unlockedBalanceXmr: '1.00000000' + }) + ); + }); + + it('throws when wallet status cannot be loaded', async () => { + walletRpcClient.getBalance.mockRejectedValue(new Error('rpc down')); + + await expect(service.getStatus()).rejects.toThrow( + new ServiceUnavailableException( + 'Could not load wallet status. The Monero wallet may be busy or unavailable.' + ) + ); + }); + + it('rejects withdrawals when there is no unlocked balance', async () => { + walletRpcClient.getBalance.mockResolvedValue({ + balanceAtomic: '0', + unlockedBalanceAtomic: '0' + }); + + await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow( + new BadRequestException('No unlocked balance to withdraw.') + ); + + expect(authService.verifyPassword).toHaveBeenCalledWith('password'); + expect(walletRpcClient.sweepAll).not.toHaveBeenCalled(); + }); + + it('rejects withdrawals while the wallet is still syncing', async () => { + walletRpcClient.getHeight.mockResolvedValue(2_999_000); + + await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow( + new BadRequestException('Wallet is still syncing. Try again after sync completes.') + ); + }); + + it('sweeps unlocked funds when the wallet is synced', async () => { + const result = await service.withdrawAll('4DestinationAddressExample', 'password'); + + expect(walletRpcClient.sweepAll).toHaveBeenCalledWith('4DestinationAddressExample'); + expect(result).toEqual({ + txHashes: ['tx-hash-1'], + amountXmr: '1.00000000' + }); + }); + + it('reveals the wallet seed after password verification', async () => { + await expect(service.revealSeed('password')).resolves.toEqual({ mnemonic: 'seed words' }); + + expect(authService.verifyPassword).toHaveBeenCalledWith('password'); + expect(walletRpcClient.queryMnemonic).toHaveBeenCalled(); + }); + + it('reports unknown sync status when the daemon height cannot be fetched', async () => { + mockedAxios.post.mockRejectedValue(new Error('daemon down')); + + const status = await service.getStatus(); + + expect(status.syncStatus).toBe(MoneroWalletSyncStatus.Unknown); + expect(status.daemonHeight).toBeNull(); + }); + + it('treats the wallet as synced when it is one block behind the daemon', async () => { + walletRpcClient.getHeight.mockResolvedValue(2_999_999); + mockedAxios.post.mockResolvedValue({ + data: { result: { height: 3_000_000 } } + }); + + const status = await service.getStatus(); + + expect(status.syncStatus).toBe(MoneroWalletSyncStatus.Synced); + }); + + it('throws when reveal seed RPC fails', async () => { + walletRpcClient.queryMnemonic.mockRejectedValue(new Error('rpc down')); + + await expect(service.revealSeed('password')).rejects.toThrow( + new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.') + ); + }); + + it('throws when sweep all fails after prechecks pass', async () => { + walletRpcClient.sweepAll.mockRejectedValue(new Error('sweep failed')); + + await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow( + new ServiceUnavailableException( + 'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.' + ) + ); + }); +}); diff --git a/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.ts b/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.ts new file mode 100644 index 0000000..4ebc2d1 --- /dev/null +++ b/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.ts @@ -0,0 +1,135 @@ +import { BadRequestException, Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; +import { AuthService } from '../../auth/services/AuthService'; +import { convertXmrAtomicToXmr } from '../../../utils/monero/convertXmrAtomicToXmr'; +import type { Config } from '../../../types/Config'; +import type { MoneroDaemonGetInfoResult } from '../types/MoneroDaemonGetInfoResult'; +import type { MoneroWalletRevealSeedResult } from '../types/MoneroWalletRevealSeedResult'; +import type { MoneroWalletStatusView } from '../types/MoneroWalletStatusView'; +import { MoneroWalletSyncStatus } from '../types/MoneroWalletSyncStatus'; +import type { MoneroWalletWithdrawResult } from '../types/MoneroWalletWithdrawResult'; +import { MoneroWalletRpcClient } from './MoneroWalletRpcClient'; + +@Injectable() +export class MoneroWalletAdminService { + constructor( + private readonly walletRpcClient: MoneroWalletRpcClient, + private readonly authService: AuthService, + private readonly configService: ConfigService + ) {} + + async getStatus(): Promise { + const { network } = this.configService.get('moneroWallet') as Config['moneroWallet']; + + await this.walletRpcClient.tryRefresh(); + + try { + const [rpcVersion, daemonHeight, walletHeight, { balanceAtomic, unlockedBalanceAtomic }] = + await Promise.all([ + this.walletRpcClient.getVersion(), + this.fetchDaemonHeight(), + this.walletRpcClient.getHeight(), + this.walletRpcClient.getBalance() + ]); + + return { + network, + rpcVersion, + walletHeight, + daemonHeight, + syncStatus: this.resolveSyncStatus(walletHeight, daemonHeight), + balanceXmr: convertXmrAtomicToXmr(balanceAtomic), + unlockedBalanceXmr: convertXmrAtomicToXmr(unlockedBalanceAtomic) + }; + } catch { + throw new ServiceUnavailableException( + 'Could not load wallet status. The Monero wallet may be busy or unavailable.' + ); + } + } + + async withdrawAll(destinationAddress: string, password: string): Promise { + this.authService.verifyPassword(password); + + await this.walletRpcClient.tryRefresh(); + + let unlockedBalanceAtomic: string; + let walletHeight: number; + let daemonHeight: number | null; + + try { + [{ unlockedBalanceAtomic }, walletHeight, daemonHeight] = await Promise.all([ + this.walletRpcClient.getBalance(), + this.walletRpcClient.getHeight(), + this.fetchDaemonHeight() + ]); + } catch { + throw new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.'); + } + + if (unlockedBalanceAtomic === '0') { + throw new BadRequestException('No unlocked balance to withdraw.'); + } + + if (this.resolveSyncStatus(walletHeight, daemonHeight) !== MoneroWalletSyncStatus.Synced) { + throw new BadRequestException('Wallet is still syncing. Try again after sync completes.'); + } + + let txHashes: string[]; + let amountAtomic: string; + + try { + ({ txHashes, amountAtomic } = await this.walletRpcClient.sweepAll(destinationAddress)); + } catch { + throw new ServiceUnavailableException( + 'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.' + ); + } + + return { + txHashes, + amountXmr: convertXmrAtomicToXmr(amountAtomic) + }; + } + + async revealSeed(password: string): Promise { + this.authService.verifyPassword(password); + + try { + const mnemonic = await this.walletRpcClient.queryMnemonic(); + + return { mnemonic }; + } catch { + throw new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.'); + } + } + + private async fetchDaemonHeight(): Promise { + const { daemonRpcUrl, rpcTimeoutMs } = this.configService.get('moneroWallet') as Config['moneroWallet']; + + try { + const { data } = await axios.post<{ result?: MoneroDaemonGetInfoResult }>( + daemonRpcUrl, + { + jsonrpc: '2.0', + id: '0', + method: 'get_info' + }, + { timeout: rpcTimeoutMs } + ); + + return data.result?.height ?? null; + } catch { + return null; + } + } + + private resolveSyncStatus(walletHeight: number, daemonHeight: number | null): MoneroWalletSyncStatus { + if (daemonHeight === null) { + return MoneroWalletSyncStatus.Unknown; + } + + return walletHeight >= daemonHeight - 1 ? MoneroWalletSyncStatus.Synced : MoneroWalletSyncStatus.Syncing; + } +} diff --git a/backend/src/modules/moneroWallet/services/MoneroWalletRpcClient.spec.ts b/backend/src/modules/moneroWallet/services/MoneroWalletRpcClient.spec.ts new file mode 100644 index 0000000..8354083 --- /dev/null +++ b/backend/src/modules/moneroWallet/services/MoneroWalletRpcClient.spec.ts @@ -0,0 +1,220 @@ +import { ConfigService } from '@nestjs/config'; +import { createHash } from 'node:crypto'; +import type { MoneroWalletRpcClientTest } from '../types/MoneroWalletRpcClientTest'; +import { MoneroWalletRpcClient } from './MoneroWalletRpcClient'; + +jest.mock('node:crypto', () => { + const actual = jest.requireActual('node:crypto'); + + return { + ...actual, + randomBytes: jest.fn(() => Buffer.from('0123456789abcdef', 'hex')) + }; +}); + +describe('MoneroWalletRpcClient', () => { + let client: MoneroWalletRpcClientTest; + + beforeEach(() => { + client = new MoneroWalletRpcClient({ + get: jest.fn() + } as unknown as ConfigService) as unknown as MoneroWalletRpcClientTest; + }); + + describe('formatRpcVersion', () => { + it('formats the packed RPC version integer from get_version', () => { + expect(client.formatRpcVersion(65539)).toBe('1.3'); + }); + }); + + describe('parseDigestChallenge', () => { + it('parses a full digest challenge header', () => { + const header = 'Digest realm="monero-rpc", nonce="abc123", opaque="opaque-value", qop="auth"'; + + expect(client.parseDigestChallenge(header)).toEqual({ + realm: 'monero-rpc', + nonce: 'abc123', + opaque: 'opaque-value', + qop: 'auth' + }); + }); + + it('parses headers with a lowercase digest prefix', () => { + const header = 'digest realm="monero-rpc", nonce="abc123"'; + + expect(client.parseDigestChallenge(header)).toEqual({ + realm: 'monero-rpc', + nonce: 'abc123', + opaque: undefined, + qop: undefined + }); + }); + + it('throws when realm is missing', () => { + expect(() => client.parseDigestChallenge('Digest nonce="abc123"')).toThrow( + 'Invalid Monero wallet RPC digest challenge' + ); + }); + + it('throws when nonce is missing', () => { + expect(() => client.parseDigestChallenge('Digest realm="monero-rpc"')).toThrow( + 'Invalid Monero wallet RPC digest challenge' + ); + }); + }); + + describe('getIncomingTransfers', () => { + let rpcClient: MoneroWalletRpcClient; + let callSpy: jest.SpiedFunction<(method: string, params?: Record) => Promise>; + + beforeEach(() => { + rpcClient = new MoneroWalletRpcClient({ + get: jest.fn() + } as unknown as ConfigService); + + callSpy = jest.spyOn( + MoneroWalletRpcClient.prototype as unknown as { + call: (method: string, params?: Record) => Promise; + }, + 'call' + ); + }); + + afterEach(() => { + callSpy.mockRestore(); + }); + + it('returns an empty array when the RPC omits in and pool', async () => { + callSpy.mockResolvedValue({}); + + await expect(rpcClient.getIncomingTransfers([3])).resolves.toEqual([]); + }); + + it('maps confirmed and pending transfers when present', async () => { + callSpy.mockResolvedValue({ + in: [ + { + txid: 'confirmed-tx', + amount: 1000000000000, + confirmations: 2, + subaddr_index: { major: 0, minor: 3 } + } + ], + pool: [ + { + txid: 'pending-tx', + amount: 500000000000, + confirmations: 0, + subaddr_index: { major: 0, minor: 4 } + } + ] + }); + + await expect(rpcClient.getIncomingTransfers([3, 4])).resolves.toEqual([ + { + txHash: 'confirmed-tx', + amountAtomic: '1000000000000', + confirmations: 2, + subaddrIndex: 3 + }, + { + txHash: 'pending-tx', + amountAtomic: '500000000000', + confirmations: 0, + subaddrIndex: 4 + } + ]); + }); + + it('defaults missing confirmations on confirmed transfers to zero', async () => { + callSpy.mockResolvedValue({ + in: [ + { + txid: 'confirmed-tx', + amount: 1000000000000, + subaddr_index: { major: 0, minor: 3 } + } + ] + }); + + await expect(rpcClient.getIncomingTransfers([3])).resolves.toEqual([ + { + txHash: 'confirmed-tx', + amountAtomic: '1000000000000', + confirmations: 0, + subaddrIndex: 3 + } + ]); + }); + + it('skips malformed transfer entries', async () => { + callSpy.mockResolvedValue({ + in: [ + { amount: 1, subaddr_index: { major: 0, minor: 1 } }, + { txid: 'valid-tx', amount: 2, subaddr_index: { major: 0, minor: 2 } } + ] + }); + + await expect(rpcClient.getIncomingTransfers([1, 2])).resolves.toEqual([ + { + txHash: 'valid-tx', + amountAtomic: '2', + confirmations: 0, + subaddrIndex: 2 + } + ]); + }); + }); + + describe('buildDigestAuthorization', () => { + it('builds a digest authorization header from the challenge', () => { + const username = 'rpcuser'; + const password = 'rpcpass'; + const uri = '/json_rpc'; + const realm = 'monero-rpc'; + const nonce = 'server-nonce'; + const qop = 'auth'; + const nc = '00000001'; + const cnonce = '0123456789abcdef'; + const digestHeader = `Digest realm="${realm}", nonce="${nonce}", qop="${qop}"`; + const ha1 = createHash('md5').update(`${username}:${realm}:${password}`).digest('hex'); + const ha2 = createHash('md5').update(`POST:${uri}`).digest('hex'); + const response = createHash('md5').update(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`).digest('hex'); + + expect(client.buildDigestAuthorization(uri, username, password, digestHeader)).toBe( + `Digest username="${username}", realm="${realm}", nonce="${nonce}", uri="${uri}", qop=${qop}, nc=${nc}, cnonce="${cnonce}", response="${response}"` + ); + }); + + it('includes opaque when the challenge provides it', () => { + const digestHeader = 'Digest realm="monero-rpc", nonce="server-nonce", opaque="opaque-token", qop="auth"'; + + const authorization = client.buildDigestAuthorization('/json_rpc', 'rpcuser', 'rpcpass', digestHeader); + + expect(authorization).toContain('opaque="opaque-token"'); + }); + + it('defaults qop to auth when the challenge omits it', () => { + const digestHeader = 'Digest realm="monero-rpc", nonce="server-nonce"'; + + const authorization = client.buildDigestAuthorization('/json_rpc', 'rpcuser', 'rpcpass', digestHeader); + + expect(authorization).toContain('qop=auth'); + }); + + it('uses the first qop option when several are offered', () => { + const digestHeader = 'Digest realm="monero-rpc", nonce="server-nonce", qop="auth, auth-int"'; + + const authorization = client.buildDigestAuthorization('/json_rpc', 'rpcuser', 'rpcpass', digestHeader); + + expect(authorization).toContain('qop=auth'); + expect(authorization).not.toContain('auth-int'); + }); + + it('throws when the challenge header is invalid', () => { + expect(() => + client.buildDigestAuthorization('/json_rpc', 'rpcuser', 'rpcpass', 'Digest qop="auth"') + ).toThrow('Invalid Monero wallet RPC digest challenge'); + }); + }); +}); diff --git a/backend/src/modules/moneroWallet/services/MoneroWalletRpcClient.ts b/backend/src/modules/moneroWallet/services/MoneroWalletRpcClient.ts new file mode 100644 index 0000000..f7748fd --- /dev/null +++ b/backend/src/modules/moneroWallet/services/MoneroWalletRpcClient.ts @@ -0,0 +1,282 @@ +import { HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; +import { createHash, randomBytes } from 'node:crypto'; +import type { Config } from '../../../types/Config'; +import { getErrorMessage } from '../../../utils/getErrorMessage'; +import type { MoneroCreateAddressResult } from '../types/MoneroCreateAddressResult'; +import type { MoneroWalletRpcIncomingTransfer } from '../types/MoneroWalletRpcIncomingTransfer'; +import type { MoneroWalletRpcGetTransfersResult } from '../types/MoneroWalletRpcGetTransfersResult'; +import type { MoneroWalletRpcTransferEntry } from '../types/MoneroWalletRpcTransferEntry'; +import type { MoneroWalletRpcGetBalanceResult } from '../types/MoneroWalletRpcGetBalanceResult'; +import type { MoneroWalletRpcGetHeightResult } from '../types/MoneroWalletRpcGetHeightResult'; +import type { MoneroWalletRpcGetVersionResult } from '../types/MoneroWalletRpcGetVersionResult'; +import type { MoneroWalletRpcQueryKeyResult } from '../types/MoneroWalletRpcQueryKeyResult'; +import type { MoneroWalletRpcSweepAllResult } from '../types/MoneroWalletRpcSweepAllResult'; +import type { MoneroWalletRpcDigestChallenge } from '../types/MoneroWalletRpcDigestChallenge'; +import type { MoneroWalletRpcResponse } from '../types/MoneroWalletRpcResponse'; + +@Injectable() +export class MoneroWalletRpcClient { + private readonly logger = new Logger(MoneroWalletRpcClient.name); + private readonly accountIndex = 0; + + constructor(private readonly configService: ConfigService) {} + + private async call( + method: string, + params: Record = {}, + options: { timeoutMs?: number } = {} + ): Promise { + const { rpcUrl, rpcTimeoutMs } = this.configService.get('moneroWallet') as Config['moneroWallet']; + + const timeoutMs = options.timeoutMs ?? rpcTimeoutMs; + + const body = { method, params }; + + const authorization = await this.doDigestAuthorization(body); + + const { data } = await axios.post>(rpcUrl, body, { + timeout: timeoutMs, + headers: { Authorization: authorization } + }); + + if (data.error) { + throw new Error(data.error.message); + } + + if (data.result === undefined) { + throw new Error(`Monero wallet RPC ${method} returned no result`); + } + + return data.result; + } + + private async doDigestAuthorization(body: { method: string; params: Record }): Promise { + const { rpcUrl, username, password, rpcTimeoutMs } = this.configService.get( + 'moneroWallet' + ) as Config['moneroWallet']; + + const rpcUri = new URL(rpcUrl); + const requestPath = `${rpcUri.pathname}${rpcUri.search}`; + + const challengeResponse = await axios.post(rpcUrl, body, { + timeout: rpcTimeoutMs, + validateStatus: (status: HttpStatus) => status === HttpStatus.UNAUTHORIZED + }); + + const digestHeader = challengeResponse.headers['www-authenticate'] as unknown; + + if (!digestHeader || typeof digestHeader !== 'string') { + throw new Error(`Monero wallet RPC ${body.method} did not return a digest auth challenge`); + } + + return this.buildDigestAuthorization(requestPath, username, password, digestHeader); + } + + private buildDigestAuthorization(uri: string, username: string, password: string, digestHeader: string): string { + const challenge = this.parseDigestChallenge(digestHeader); + const ha1 = createHash('md5').update(`${username}:${challenge.realm}:${password}`).digest('hex'); + const ha2 = createHash('md5').update(`POST:${uri}`).digest('hex'); + const nc = '00000001'; + const cnonce = randomBytes(8).toString('hex'); + const qop = challenge.qop?.split(',')[0]?.trim() || 'auth'; + const response = createHash('md5') + .update(`${ha1}:${challenge.nonce}:${nc}:${cnonce}:${qop}:${ha2}`) + .digest('hex'); + + const parts = [ + `username="${username}"`, + `realm="${challenge.realm}"`, + `nonce="${challenge.nonce}"`, + `uri="${uri}"`, + `qop=${qop}`, + `nc=${nc}`, + `cnonce="${cnonce}"`, + `response="${response}"` + ]; + + if (challenge.opaque) { + parts.push(`opaque="${challenge.opaque}"`); + } + + return `Digest ${parts.join(', ')}`; + } + + private parseDigestChallenge(header: string): MoneroWalletRpcDigestChallenge { + const params = Object.fromEntries( + header + .replace(/^Digest\s+/i, '') + .split(',') + .map(part => { + const [key, ...valueParts] = part.trim().split('='); + + return [key, valueParts.join('=').replace(/^"|"$/g, '')]; + }) + ); + + if (!params.realm || !params.nonce) { + throw new Error('Invalid Monero wallet RPC digest challenge'); + } + + return { + realm: params.realm, + nonce: params.nonce, + opaque: params.opaque, + qop: params.qop + }; + } + + async getVersion(): Promise { + const { version, release } = await this.call('get_version'); + + if (version === undefined) { + throw new Error('Monero wallet RPC get_version returned no version'); + } + + const formatted = this.formatRpcVersion(version); + + if (release === false) { + return `${formatted} (non-release)`; + } + + return formatted; + } + + private formatRpcVersion(version: number): string { + const major = version >>> 16; + const minor = version & 0xffff; + + return `${major}.${minor}`; + } + + async createAddress(label?: string): Promise<{ address: string; address_index: number }> { + const params: { account_index: number; label?: string } = { account_index: this.accountIndex }; + + if (label) { + params.label = label; + } + + const result = await this.call('create_address', params); + + if (!result.address || result.address_index === undefined) { + throw new Error('Monero wallet RPC create_address returned incomplete result'); + } + + return { address: result.address, address_index: result.address_index }; + } + + async getIncomingTransfers(subaddrIndices: number[]): Promise { + if (subaddrIndices.length === 0) { + return []; + } + + const result = await this.call('get_transfers', { + in: true, + pool: true, + account_index: this.accountIndex, + subaddr_indices: subaddrIndices + }); + + const confirmed = (result.in ?? []) + .map(transfer => this.mapIncomingTransfer(transfer, transfer.confirmations ?? 0)) + .filter((transfer): transfer is MoneroWalletRpcIncomingTransfer => transfer !== null); + + const pending = (result.pool ?? []) + .map(transfer => this.mapIncomingTransfer(transfer, 0)) + .filter((transfer): transfer is MoneroWalletRpcIncomingTransfer => transfer !== null); + + return [...confirmed, ...pending]; + } + + async tryRefresh(): Promise { + try { + await this.call('refresh'); + } catch (error) { + this.logger.warn(`Monero wallet refresh failed: ${getErrorMessage(error)}`); + } + } + + async getHeight(): Promise { + const { height } = await this.call('get_height'); + + if (height === undefined) { + throw new Error('Monero wallet RPC get_height returned no height'); + } + + return height; + } + + async getBalance(): Promise<{ balanceAtomic: string; unlockedBalanceAtomic: string }> { + const { balance, unlocked_balance } = await this.call('get_balance', { + account_index: this.accountIndex + }); + + if (balance === undefined || unlocked_balance === undefined) { + throw new Error('Monero wallet RPC get_balance returned incomplete result'); + } + + return { + balanceAtomic: String(balance), + unlockedBalanceAtomic: String(unlocked_balance) + }; + } + + async sweepAll(destinationAddress: string): Promise<{ txHashes: string[]; amountAtomic: string }> { + const result = await this.call( + 'sweep_all', + { + address: destinationAddress, + account_index: this.accountIndex, + subaddr_indices_all: true, + priority: 1 + }, + { timeoutMs: 120_000 } + ); + + const txHashes = result.tx_hash_list; + + if (!txHashes?.length) { + throw new Error('Monero wallet RPC sweep_all returned no transaction hashes'); + } + + const sweptAmount = (result.amount_list ?? []).reduce((sum, amount) => sum + amount, 0); + + return { + txHashes, + amountAtomic: String(sweptAmount) + }; + } + + async queryMnemonic(): Promise { + const { key } = await this.call('query_key', { + key_type: 'mnemonic' + }); + + const trimmedKey = key?.trim(); + + if (!trimmedKey) { + throw new Error('Monero wallet RPC query_key returned no mnemonic'); + } + + return trimmedKey; + } + + private mapIncomingTransfer( + transfer: MoneroWalletRpcTransferEntry, + confirmations: number + ): MoneroWalletRpcIncomingTransfer | null { + const txHash = transfer.txid?.trim(); + + if (!txHash || transfer.amount === undefined || transfer.subaddr_index?.minor === undefined) { + return null; + } + + return { + txHash, + amountAtomic: String(transfer.amount), + confirmations, + subaddrIndex: transfer.subaddr_index.minor + }; + } +} diff --git a/backend/src/modules/moneroWallet/services/MoneroWalletRpcConnectionService.spec.ts b/backend/src/modules/moneroWallet/services/MoneroWalletRpcConnectionService.spec.ts new file mode 100644 index 0000000..e4b7c96 --- /dev/null +++ b/backend/src/modules/moneroWallet/services/MoneroWalletRpcConnectionService.spec.ts @@ -0,0 +1,43 @@ +import { Logger } from '@nestjs/common'; +import type { MoneroWalletRpcClient } from './MoneroWalletRpcClient'; +import { MoneroWalletRpcConnectionService } from './MoneroWalletRpcConnectionService'; + +describe('MoneroWalletRpcConnectionService', () => { + let service: MoneroWalletRpcConnectionService; + let walletRpcClient: { + getVersion: jest.Mock; + }; + let logSpy: jest.SpiedFunction; + let errorSpy: jest.SpiedFunction; + + beforeEach(() => { + logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + errorSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + + walletRpcClient = { + getVersion: jest.fn().mockResolvedValue('0.18.3.1') + }; + + service = new MoneroWalletRpcConnectionService(walletRpcClient as unknown as MoneroWalletRpcClient); + }); + + afterEach(() => { + logSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it('logs a successful wallet rpc connection on module init', async () => { + await service.onModuleInit(); + + expect(walletRpcClient.getVersion).toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith('Connected to monero-wallet-rpc (version 0.18.3.1)'); + }); + + it('logs an error when wallet rpc is unreachable at startup', async () => { + walletRpcClient.getVersion.mockRejectedValue(new Error('connection refused')); + + await service.onModuleInit(); + + expect(errorSpy).toHaveBeenCalledWith('Failed to reach monero-wallet-rpc at startup: connection refused'); + }); +}); diff --git a/backend/src/modules/moneroWallet/services/MoneroWalletRpcConnectionService.ts b/backend/src/modules/moneroWallet/services/MoneroWalletRpcConnectionService.ts new file mode 100644 index 0000000..acfae08 --- /dev/null +++ b/backend/src/modules/moneroWallet/services/MoneroWalletRpcConnectionService.ts @@ -0,0 +1,20 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { getErrorMessage } from '../../../utils/getErrorMessage'; +import { MoneroWalletRpcClient } from './MoneroWalletRpcClient'; + +@Injectable() +export class MoneroWalletRpcConnectionService implements OnModuleInit { + private readonly logger = new Logger(MoneroWalletRpcConnectionService.name); + + constructor(private readonly walletRpcClient: MoneroWalletRpcClient) {} + + async onModuleInit(): Promise { + try { + const version = await this.walletRpcClient.getVersion(); + + this.logger.log(`Connected to monero-wallet-rpc (version ${version})`); + } catch (error) { + this.logger.error(`Failed to reach monero-wallet-rpc at startup: ${getErrorMessage(error)}`); + } + } +} diff --git a/backend/src/modules/moneroWallet/types/MoneroCreateAddressResult.ts b/backend/src/modules/moneroWallet/types/MoneroCreateAddressResult.ts new file mode 100644 index 0000000..690aa04 --- /dev/null +++ b/backend/src/modules/moneroWallet/types/MoneroCreateAddressResult.ts @@ -0,0 +1,6 @@ +export type MoneroCreateAddressResult = { + address?: string; + address_index?: number; + address_indices?: number[]; + addresses?: string[]; +}; diff --git a/backend/src/modules/moneroWallet/types/MoneroDaemonGetInfoResult.ts b/backend/src/modules/moneroWallet/types/MoneroDaemonGetInfoResult.ts new file mode 100644 index 0000000..59cd685 --- /dev/null +++ b/backend/src/modules/moneroWallet/types/MoneroDaemonGetInfoResult.ts @@ -0,0 +1,3 @@ +export interface MoneroDaemonGetInfoResult { + height?: number; +} diff --git a/backend/src/modules/moneroWallet/types/MoneroWalletRevealSeedResult.ts b/backend/src/modules/moneroWallet/types/MoneroWalletRevealSeedResult.ts new file mode 100644 index 0000000..1884638 --- /dev/null +++ b/backend/src/modules/moneroWallet/types/MoneroWalletRevealSeedResult.ts @@ -0,0 +1,3 @@ +export interface MoneroWalletRevealSeedResult { + mnemonic: string; +} diff --git a/backend/src/modules/moneroWallet/types/MoneroWalletRpcClientTest.ts b/backend/src/modules/moneroWallet/types/MoneroWalletRpcClientTest.ts new file mode 100644 index 0000000..d2176ca --- /dev/null +++ b/backend/src/modules/moneroWallet/types/MoneroWalletRpcClientTest.ts @@ -0,0 +1,7 @@ +import type { MoneroWalletRpcDigestChallenge } from './MoneroWalletRpcDigestChallenge'; + +export type MoneroWalletRpcClientTest = { + formatRpcVersion(version: number): string; + parseDigestChallenge(header: string): MoneroWalletRpcDigestChallenge; + buildDigestAuthorization(uri: string, username: string, password: string, digestHeader: string): string; +}; diff --git a/backend/src/modules/moneroWallet/types/MoneroWalletRpcDigestChallenge.ts b/backend/src/modules/moneroWallet/types/MoneroWalletRpcDigestChallenge.ts new file mode 100644 index 0000000..107af22 --- /dev/null +++ b/backend/src/modules/moneroWallet/types/MoneroWalletRpcDigestChallenge.ts @@ -0,0 +1,6 @@ +export type MoneroWalletRpcDigestChallenge = { + realm: string; + nonce: string; + opaque?: string; + qop?: string; +}; diff --git a/backend/src/modules/moneroWallet/types/MoneroWalletRpcError.ts b/backend/src/modules/moneroWallet/types/MoneroWalletRpcError.ts new file mode 100644 index 0000000..84d64af --- /dev/null +++ b/backend/src/modules/moneroWallet/types/MoneroWalletRpcError.ts @@ -0,0 +1,4 @@ +export type MoneroWalletRpcError = { + code: number; + message: string; +}; diff --git a/backend/src/modules/moneroWallet/types/MoneroWalletRpcGetBalanceResult.ts b/backend/src/modules/moneroWallet/types/MoneroWalletRpcGetBalanceResult.ts new file mode 100644 index 0000000..97f45a8 --- /dev/null +++ b/backend/src/modules/moneroWallet/types/MoneroWalletRpcGetBalanceResult.ts @@ -0,0 +1,4 @@ +export interface MoneroWalletRpcGetBalanceResult { + balance?: number; + unlocked_balance?: number; +} diff --git a/backend/src/modules/moneroWallet/types/MoneroWalletRpcGetHeightResult.ts b/backend/src/modules/moneroWallet/types/MoneroWalletRpcGetHeightResult.ts new file mode 100644 index 0000000..534dd92 --- /dev/null +++ b/backend/src/modules/moneroWallet/types/MoneroWalletRpcGetHeightResult.ts @@ -0,0 +1,3 @@ +export interface MoneroWalletRpcGetHeightResult { + height?: number; +} diff --git a/backend/src/modules/moneroWallet/types/MoneroWalletRpcGetTransfersResult.ts b/backend/src/modules/moneroWallet/types/MoneroWalletRpcGetTransfersResult.ts new file mode 100644 index 0000000..3a852ad --- /dev/null +++ b/backend/src/modules/moneroWallet/types/MoneroWalletRpcGetTransfersResult.ts @@ -0,0 +1,9 @@ +import type { MoneroWalletRpcTransferEntry } from './MoneroWalletRpcTransferEntry'; + +export type MoneroWalletRpcGetTransfersResult = { + in?: MoneroWalletRpcTransferEntry[]; + out?: MoneroWalletRpcTransferEntry[]; + pending?: MoneroWalletRpcTransferEntry[]; + failed?: MoneroWalletRpcTransferEntry[]; + pool?: MoneroWalletRpcTransferEntry[]; +}; diff --git a/backend/src/modules/moneroWallet/types/MoneroWalletRpcGetVersionResult.ts b/backend/src/modules/moneroWallet/types/MoneroWalletRpcGetVersionResult.ts new file mode 100644 index 0000000..42a4e38 --- /dev/null +++ b/backend/src/modules/moneroWallet/types/MoneroWalletRpcGetVersionResult.ts @@ -0,0 +1,4 @@ +export type MoneroWalletRpcGetVersionResult = { + version?: number; + release?: boolean; +}; diff --git a/backend/src/modules/moneroWallet/types/MoneroWalletRpcIncomingTransfer.ts b/backend/src/modules/moneroWallet/types/MoneroWalletRpcIncomingTransfer.ts new file mode 100644 index 0000000..b67b4f1 --- /dev/null +++ b/backend/src/modules/moneroWallet/types/MoneroWalletRpcIncomingTransfer.ts @@ -0,0 +1,6 @@ +export type MoneroWalletRpcIncomingTransfer = { + txHash: string; + amountAtomic: string; + confirmations: number; + subaddrIndex: number; +}; diff --git a/backend/src/modules/moneroWallet/types/MoneroWalletRpcQueryKeyResult.ts b/backend/src/modules/moneroWallet/types/MoneroWalletRpcQueryKeyResult.ts new file mode 100644 index 0000000..0cd5948 --- /dev/null +++ b/backend/src/modules/moneroWallet/types/MoneroWalletRpcQueryKeyResult.ts @@ -0,0 +1,3 @@ +export interface MoneroWalletRpcQueryKeyResult { + key?: string; +} diff --git a/backend/src/modules/moneroWallet/types/MoneroWalletRpcResponse.ts b/backend/src/modules/moneroWallet/types/MoneroWalletRpcResponse.ts new file mode 100644 index 0000000..e33a7c8 --- /dev/null +++ b/backend/src/modules/moneroWallet/types/MoneroWalletRpcResponse.ts @@ -0,0 +1,8 @@ +import type { MoneroWalletRpcError } from './MoneroWalletRpcError'; + +export type MoneroWalletRpcResponse = { + id: string; + jsonrpc: string; + result?: T; + error?: MoneroWalletRpcError; +}; diff --git a/backend/src/modules/moneroWallet/types/MoneroWalletRpcSubaddrIndex.ts b/backend/src/modules/moneroWallet/types/MoneroWalletRpcSubaddrIndex.ts new file mode 100644 index 0000000..73b74e8 --- /dev/null +++ b/backend/src/modules/moneroWallet/types/MoneroWalletRpcSubaddrIndex.ts @@ -0,0 +1,4 @@ +export type MoneroWalletRpcSubaddrIndex = { + major?: number; + minor?: number; +}; diff --git a/backend/src/modules/moneroWallet/types/MoneroWalletRpcSweepAllResult.ts b/backend/src/modules/moneroWallet/types/MoneroWalletRpcSweepAllResult.ts new file mode 100644 index 0000000..67cc4cc --- /dev/null +++ b/backend/src/modules/moneroWallet/types/MoneroWalletRpcSweepAllResult.ts @@ -0,0 +1,4 @@ +export interface MoneroWalletRpcSweepAllResult { + tx_hash_list?: string[]; + amount_list?: number[]; +} diff --git a/backend/src/modules/moneroWallet/types/MoneroWalletRpcTransferEntry.ts b/backend/src/modules/moneroWallet/types/MoneroWalletRpcTransferEntry.ts new file mode 100644 index 0000000..4eca0fc --- /dev/null +++ b/backend/src/modules/moneroWallet/types/MoneroWalletRpcTransferEntry.ts @@ -0,0 +1,14 @@ +import type { MoneroWalletRpcSubaddrIndex } from './MoneroWalletRpcSubaddrIndex'; + +export type MoneroWalletRpcTransferEntry = { + txid?: string; + amount?: number; + confirmations?: number; + subaddr_index?: MoneroWalletRpcSubaddrIndex; + address?: string; + height?: number; + timestamp?: number; + type?: string; + locked?: boolean; + amounts?: number[]; +}; diff --git a/backend/src/modules/moneroWallet/types/MoneroWalletStatusView.ts b/backend/src/modules/moneroWallet/types/MoneroWalletStatusView.ts new file mode 100644 index 0000000..cfc2254 --- /dev/null +++ b/backend/src/modules/moneroWallet/types/MoneroWalletStatusView.ts @@ -0,0 +1,12 @@ +import { MoneroNetwork } from '../../../types/MoneroNetwork'; +import { MoneroWalletSyncStatus } from './MoneroWalletSyncStatus'; + +export interface MoneroWalletStatusView { + network: MoneroNetwork; + rpcVersion: string; + walletHeight: number; + daemonHeight: number | null; + syncStatus: MoneroWalletSyncStatus; + balanceXmr: string; + unlockedBalanceXmr: string; +} diff --git a/backend/src/modules/moneroWallet/types/MoneroWalletSyncStatus.ts b/backend/src/modules/moneroWallet/types/MoneroWalletSyncStatus.ts new file mode 100644 index 0000000..c17d00a --- /dev/null +++ b/backend/src/modules/moneroWallet/types/MoneroWalletSyncStatus.ts @@ -0,0 +1,5 @@ +export enum MoneroWalletSyncStatus { + Synced = 'synced', + Syncing = 'syncing', + Unknown = 'unknown' +} diff --git a/backend/src/modules/moneroWallet/types/MoneroWalletWithdrawResult.ts b/backend/src/modules/moneroWallet/types/MoneroWalletWithdrawResult.ts new file mode 100644 index 0000000..d4e6f0e --- /dev/null +++ b/backend/src/modules/moneroWallet/types/MoneroWalletWithdrawResult.ts @@ -0,0 +1,4 @@ +export interface MoneroWalletWithdrawResult { + txHashes: string[]; + amountXmr: string; +} diff --git a/backend/src/modules/notifications/NotificationsModule.ts b/backend/src/modules/notifications/NotificationsModule.ts new file mode 100644 index 0000000..3697a86 --- /dev/null +++ b/backend/src/modules/notifications/NotificationsModule.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ShopSettingsModule } from '../shopSettings/ShopSettingsModule'; +import { SimplexModule } from '../simplex/SimplexModule'; +import { NotificationService } from './services/NotificationService'; + +@Module({ + imports: [SimplexModule, ShopSettingsModule], + providers: [NotificationService], + exports: [NotificationService] +}) +export class NotificationsModule {} diff --git a/backend/src/modules/notifications/services/NotificationService.spec.ts b/backend/src/modules/notifications/services/NotificationService.spec.ts new file mode 100644 index 0000000..121eb83 --- /dev/null +++ b/backend/src/modules/notifications/services/NotificationService.spec.ts @@ -0,0 +1,74 @@ +import { Logger } from '@nestjs/common'; +import type { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService'; +import type { SimplexChatClient } from '../../simplex/services/SimplexChatClient'; +import { NotificationService } from './NotificationService'; + +describe('NotificationService', () => { + let service: NotificationService; + let shopSettingsService: { + findSettings: jest.Mock; + }; + let simplexChatClient: { + sendText: jest.Mock; + }; + let warnSpy: jest.SpiedFunction; + + beforeEach(() => { + warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + + shopSettingsService = { + findSettings: jest.fn().mockResolvedValue({ + notificationsEnabled: true, + simplexNotificationContactId: 42, + notifyOnNewOrder: true, + notifyOnOrderMessage: false + }) + }; + + simplexChatClient = { + sendText: jest.fn().mockResolvedValue(undefined) + }; + + service = new NotificationService( + shopSettingsService as unknown as ShopSettingsService, + simplexChatClient as unknown as SimplexChatClient + ); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('does nothing when notifications are disabled', async () => { + shopSettingsService.findSettings.mockResolvedValue({ + notificationsEnabled: false, + simplexNotificationContactId: 42, + notifyOnNewOrder: true, + notifyOnOrderMessage: true + }); + + await service.sendNotification('order-1234-abcd', 'newOrder'); + + expect(simplexChatClient.sendText).not.toHaveBeenCalled(); + }); + + it('does nothing when the notification type is disabled', async () => { + await service.sendNotification('order-1234-abcd', 'newBuyerMessage'); + + expect(simplexChatClient.sendText).not.toHaveBeenCalled(); + }); + + it('sends a new order notification when enabled', async () => { + await service.sendNotification('order-1234-abcd', 'newOrder'); + + expect(simplexChatClient.sendText).toHaveBeenCalledWith(42, 'New order #orde — open CMS Orders.'); + }); + + it('logs a warning when sending fails without throwing', async () => { + simplexChatClient.sendText.mockRejectedValue(new Error('simplex down')); + + await expect(service.sendNotification('order-1234-abcd', 'newOrder')).resolves.toBeUndefined(); + + expect(warnSpy).toHaveBeenCalledWith('Failed to send newOrder notification: simplex down'); + }); +}); diff --git a/backend/src/modules/notifications/services/NotificationService.ts b/backend/src/modules/notifications/services/NotificationService.ts new file mode 100644 index 0000000..a8cdd4b --- /dev/null +++ b/backend/src/modules/notifications/services/NotificationService.ts @@ -0,0 +1,42 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { getErrorMessage } from '../../../utils/getErrorMessage'; +import { formatShortOrderId } from '../../../utils/order/formatShortOrderId'; +import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService'; +import { SimplexChatClient } from '../../simplex/services/SimplexChatClient'; + +@Injectable() +export class NotificationService { + private readonly logger = new Logger(NotificationService.name); + + constructor( + private readonly shopSettingsService: ShopSettingsService, + private readonly simplexChatClient: SimplexChatClient + ) {} + + async sendNotification(orderId: string, type: 'newOrder' | 'newBuyerMessage'): Promise { + try { + const settings = await this.shopSettingsService.findSettings(); + + if (!settings.notificationsEnabled || settings.simplexNotificationContactId === null) { + return; + } + + const enabled = type === 'newOrder' ? settings.notifyOnNewOrder : settings.notifyOnOrderMessage; + + if (!enabled) { + return; + } + + const shortId = formatShortOrderId(orderId); + + const message = + type === 'newOrder' + ? `New order ${shortId} — open CMS Orders.` + : `New message on order ${shortId}.`; + + await this.simplexChatClient.sendText(settings.simplexNotificationContactId, message); + } catch (error) { + this.logger.warn(`Failed to send ${type} notification: ${getErrorMessage(error)}`); + } + } +} diff --git a/backend/src/modules/order/OrderModule.ts b/backend/src/modules/order/OrderModule.ts new file mode 100644 index 0000000..e06379b --- /dev/null +++ b/backend/src/modules/order/OrderModule.ts @@ -0,0 +1,40 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { EncryptionModule } from '../encryption/EncryptionModule'; +import { NotificationsModule } from '../notifications/NotificationsModule'; +import { PaymentModule } from '../payment/PaymentModule'; +import { OrderChatController } from './controllers/OrderChatController'; +import { OrdersController } from './controllers/OrdersController'; +import { Order } from './entities/Order'; +import { OrderLineAutoFulfillmentItemAttachment } from './entities/OrderLineAutoFulfillmentItemAttachment'; +import { OrderLineManualFulfillment } from './entities/OrderLineManualFulfillment'; +import { OrderMessage } from './entities/OrderMessage'; +import { OrderClaimService } from './services/OrderClaimService'; +import { OrderAccessTokenService } from './services/OrderAccessTokenService'; +import { OrderCreationService } from './services/OrderCreationService'; +import { OrderChatService } from './services/OrderChatService'; +import { OrderService } from './services/OrderService'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Order, + OrderMessage, + OrderLineAutoFulfillmentItemAttachment, + OrderLineManualFulfillment + ]), + EncryptionModule, + PaymentModule, + NotificationsModule + ], + controllers: [OrdersController, OrderChatController], + providers: [OrderService, OrderClaimService, OrderCreationService, OrderAccessTokenService, OrderChatService], + exports: [ + OrderService, + OrderCreationService, + OrderAccessTokenService, + OrderChatService, + TypeOrmModule.forFeature([Order, OrderLineAutoFulfillmentItemAttachment]) + ] +}) +export class OrderModule {} diff --git a/backend/src/modules/order/controllers/OrderChatController.ts b/backend/src/modules/order/controllers/OrderChatController.ts new file mode 100644 index 0000000..35415d7 --- /dev/null +++ b/backend/src/modules/order/controllers/OrderChatController.ts @@ -0,0 +1,34 @@ +import { Body, Controller, Delete, HttpCode, HttpStatus, Param, ParseUUIDPipe, Post, UseGuards } from '@nestjs/common'; +import { JwtGuard } from '../../../guards/JwtGuard'; +import { SubmitOrderMessageDto } from '../dto/SubmitOrderMessageDto'; +import { OrderMessage } from '../entities/OrderMessage'; +import { OrderChatService } from '../services/OrderChatService'; +import { OrderMessageSender } from '../types/OrderMessageSender'; + +@Controller('orders/:orderId/messages') +@UseGuards(JwtGuard) +export class OrderChatController { + constructor(private readonly orderChatService: OrderChatService) {} + + @Post() + createMessage( + @Param('orderId', ParseUUIDPipe) orderId: string, + @Body() { body }: SubmitOrderMessageDto + ): Promise { + return this.orderChatService.createMessage(orderId, OrderMessageSender.Staff, body); + } + + @Post('mark-read') + @HttpCode(HttpStatus.NO_CONTENT) + markChatRead(@Param('orderId', ParseUUIDPipe) orderId: string): Promise { + return this.orderChatService.markChatRead(orderId); + } + + @Delete(':messageId') + deleteMessage( + @Param('orderId', ParseUUIDPipe) orderId: string, + @Param('messageId', ParseUUIDPipe) messageId: string + ): Promise { + return this.orderChatService.deleteMessage(orderId, messageId, OrderMessageSender.Staff); + } +} diff --git a/backend/src/modules/order/controllers/OrdersController.ts b/backend/src/modules/order/controllers/OrdersController.ts new file mode 100644 index 0000000..dc4acc8 --- /dev/null +++ b/backend/src/modules/order/controllers/OrdersController.ts @@ -0,0 +1,31 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query, UseGuards } from '@nestjs/common'; +import { JwtGuard } from '../../../guards/JwtGuard'; +import { ListOrdersQueryDto } from '../dto/ListOrdersQueryDto'; +import { SetDeliveryCostDto } from '../dto/SetDeliveryCostDto'; +import { OrderService } from '../services/OrderService'; + +@Controller('orders') +@UseGuards(JwtGuard) +export class OrdersController { + constructor(private readonly orderService: OrderService) {} + + @Get() + findAll(@Query() query: ListOrdersQueryDto) { + return this.orderService.findAll(query); + } + + @Get(':id') + findById(@Param('id', ParseUUIDPipe) id: string) { + return this.orderService.findById(id); + } + + @Post(':id/delivery-cost') + setDeliveryCost(@Param('id', ParseUUIDPipe) id: string, @Body() payload: SetDeliveryCostDto) { + return this.orderService.setDeliveryCost(id, payload); + } + + @Post(':id/lines/:lineId/fulfill') + fulfillManualLine(@Param('id', ParseUUIDPipe) id: string, @Param('lineId', ParseUUIDPipe) lineId: string) { + return this.orderService.fulfillManualLine(id, lineId); + } +} diff --git a/backend/src/modules/order/dto/ListOrdersQueryDto.ts b/backend/src/modules/order/dto/ListOrdersQueryDto.ts new file mode 100644 index 0000000..daac023 --- /dev/null +++ b/backend/src/modules/order/dto/ListOrdersQueryDto.ts @@ -0,0 +1,17 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class ListOrdersQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; +} diff --git a/backend/src/modules/order/dto/SetDeliveryCostDto.ts b/backend/src/modules/order/dto/SetDeliveryCostDto.ts new file mode 100644 index 0000000..b335c60 --- /dev/null +++ b/backend/src/modules/order/dto/SetDeliveryCostDto.ts @@ -0,0 +1,8 @@ +import { IsNotEmpty, IsNumber, Min } from 'class-validator'; + +export class SetDeliveryCostDto { + @IsNotEmpty() + @IsNumber() + @Min(0) + deliveryCost: number; +} diff --git a/backend/src/modules/order/dto/SubmitOrderMessageDto.ts b/backend/src/modules/order/dto/SubmitOrderMessageDto.ts new file mode 100644 index 0000000..eb3ba1f --- /dev/null +++ b/backend/src/modules/order/dto/SubmitOrderMessageDto.ts @@ -0,0 +1,16 @@ +import { Transform } from 'class-transformer'; +import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator'; +import { getAppConfig } from '../../../config'; + +const { + validation: { orderMessageMaxLength } +} = getAppConfig(); + +export class SubmitOrderMessageDto { + @Transform(({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value)) + @IsNotEmpty() + @IsString() + @MinLength(1) + @MaxLength(orderMessageMaxLength) + body: string; +} diff --git a/backend/src/modules/order/entities/Order.ts b/backend/src/modules/order/entities/Order.ts new file mode 100644 index 0000000..b942197 --- /dev/null +++ b/backend/src/modules/order/entities/Order.ts @@ -0,0 +1,67 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + OneToMany, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn +} from 'typeorm'; +import { Invoice } from '../../payment/entities/Invoice'; +import { CheckoutSession } from '../../storefrontCheckout/entities/CheckoutSession'; +import { OrderFailureReason } from '../types/OrderFailureReason'; +import { OrderDiscount } from './OrderDiscount'; +import { OrderLine } from './OrderLine'; +import { OrderMessage } from './OrderMessage'; + +@Entity('orders') +export class Order { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 64, unique: true }) + accessTokenLookup: string; + + @Column({ type: 'text', select: false }) + accessToken: string; + + @Column({ type: 'enum', enum: OrderFailureReason, nullable: true }) + failureReason: OrderFailureReason | null; + + @Column({ type: 'timestamptz', nullable: true }) + accessTokenSavedConfirmedAt: Date | null; + + @Column({ type: 'timestamptz', nullable: true }) + quotedAt: Date | null; + + @Column({ type: 'timestamptz', nullable: true }) + staffChatLastReadAt: Date | null; + + @OneToOne(() => CheckoutSession, session => session.order) + @JoinColumn() + checkoutSession: CheckoutSession; + + @OneToOne(() => Invoice) + @JoinColumn() + checkoutInvoice: Invoice; + + @OneToOne(() => Invoice) + @JoinColumn() + shippingInvoice: Invoice | null; + + @OneToMany(() => OrderLine, line => line.order, { cascade: true }) + lines: OrderLine[]; + + @OneToMany(() => OrderDiscount, discount => discount.order, { cascade: true }) + discounts: OrderDiscount[]; + + @OneToMany(() => OrderMessage, message => message.order) + messages: OrderMessage[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/modules/order/entities/OrderDiscount.ts b/backend/src/modules/order/entities/OrderDiscount.ts new file mode 100644 index 0000000..1cad05f --- /dev/null +++ b/backend/src/modules/order/entities/OrderDiscount.ts @@ -0,0 +1,24 @@ +import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer'; +import { Order } from './Order'; + +@Entity('order_discounts') +export class OrderDiscount { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => Order, order => order.discounts, { onDelete: 'CASCADE' }) + @JoinColumn() + order: Order; + + @Column({ length: 32 }) + code: string; + + @Column({ + type: 'numeric', + precision: 12, + scale: 2, + transformer: new ColumnNumericTransformer() + }) + amountFiat: number; +} diff --git a/backend/src/modules/order/entities/OrderLine.ts b/backend/src/modules/order/entities/OrderLine.ts new file mode 100644 index 0000000..c3d0c4d --- /dev/null +++ b/backend/src/modules/order/entities/OrderLine.ts @@ -0,0 +1,59 @@ +import { Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer'; +import { DeliveryMode } from '../../product/types/DeliveryMode'; +import { Order } from './Order'; +import { OrderLineAutoFulfillmentItem } from './OrderLineAutoFulfillmentItem'; +import { OrderLineManualFulfillment } from './OrderLineManualFulfillment'; + +@Entity('order_lines') +export class OrderLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => Order, order => order.lines, { onDelete: 'CASCADE' }) + @JoinColumn() + order: Order; + + @Column({ type: 'uuid' }) + variantId: string; + + @Column({ type: 'uuid' }) + productId: string; + + @Column() + productTitle: string; + + @Column() + variantTitle: string; + + @Column({ type: 'varchar', nullable: true }) + thumbnailUrl: string | null; + + @Column({ type: 'int' }) + qty: number; + + @Column({ + type: 'numeric', + precision: 12, + scale: 2, + transformer: new ColumnNumericTransformer() + }) + unitPriceFiat: number; + + @Column({ + type: 'numeric', + precision: 12, + scale: 2, + transformer: new ColumnNumericTransformer() + }) + lineSubtotalFiat: number; + + @Column({ type: 'enum', enum: DeliveryMode }) + deliveryMode: DeliveryMode; + + @OneToMany(() => OrderLineAutoFulfillmentItem, item => item.orderLine, { cascade: true }) + autoFulfillmentItems: OrderLineAutoFulfillmentItem[]; + + @OneToOne(() => OrderLineManualFulfillment, manualFulfillment => manualFulfillment.orderLine, { cascade: true }) + manualFulfillment: OrderLineManualFulfillment | null; +} diff --git a/backend/src/modules/order/entities/OrderLineAutoFulfillmentItem.ts b/backend/src/modules/order/entities/OrderLineAutoFulfillmentItem.ts new file mode 100644 index 0000000..d10d9da --- /dev/null +++ b/backend/src/modules/order/entities/OrderLineAutoFulfillmentItem.ts @@ -0,0 +1,25 @@ +import { Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; +import { OrderLine } from './OrderLine'; +import { OrderLineAutoFulfillmentItemAttachment } from './OrderLineAutoFulfillmentItemAttachment'; + +@Entity('order_line_auto_fulfillment_items') +export class OrderLineAutoFulfillmentItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'int' }) + sortOrder: number; + + @Column({ type: 'text', select: false }) + contentSnapshot: string; + + @Column({ type: 'uuid' }) + sourceDigitalStockItemId: string; + + @ManyToOne(() => OrderLine, line => line.autoFulfillmentItems, { onDelete: 'CASCADE' }) + @JoinColumn() + orderLine: OrderLine; + + @OneToMany(() => OrderLineAutoFulfillmentItemAttachment, attachment => attachment.item, { cascade: true }) + attachments: OrderLineAutoFulfillmentItemAttachment[]; +} diff --git a/backend/src/modules/order/entities/OrderLineAutoFulfillmentItemAttachment.ts b/backend/src/modules/order/entities/OrderLineAutoFulfillmentItemAttachment.ts new file mode 100644 index 0000000..107be62 --- /dev/null +++ b/backend/src/modules/order/entities/OrderLineAutoFulfillmentItemAttachment.ts @@ -0,0 +1,26 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { OrderLineAutoFulfillmentItem } from './OrderLineAutoFulfillmentItem'; + +@Entity('order_line_auto_fulfillment_item_attachments') +export class OrderLineAutoFulfillmentItemAttachment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ select: false }) + storageKey: string; + + @Column({ type: 'uuid' }) + sourceDigitalStockAttachmentId: string; + + @Column() + originalFilename: string; + + @Column() + mimeType: string; + + @Column({ type: 'integer' }) + sizeBytes: number; + + @ManyToOne(() => OrderLineAutoFulfillmentItem, item => item.attachments, { onDelete: 'CASCADE' }) + item: OrderLineAutoFulfillmentItem; +} diff --git a/backend/src/modules/order/entities/OrderLineManualFulfillment.ts b/backend/src/modules/order/entities/OrderLineManualFulfillment.ts new file mode 100644 index 0000000..3a25be1 --- /dev/null +++ b/backend/src/modules/order/entities/OrderLineManualFulfillment.ts @@ -0,0 +1,19 @@ +import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { ManualLineFulfillmentStatus } from '../types/ManualLineFulfillmentStatus'; +import { OrderLine } from './OrderLine'; + +@Entity('order_line_manual_fulfillments') +export class OrderLineManualFulfillment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'enum', enum: ManualLineFulfillmentStatus, default: ManualLineFulfillmentStatus.Pending }) + status: ManualLineFulfillmentStatus; + + @Column({ type: 'timestamptz', nullable: true }) + fulfilledAt: Date | null; + + @OneToOne(() => OrderLine, line => line.manualFulfillment, { onDelete: 'CASCADE' }) + @JoinColumn() + orderLine: OrderLine; +} diff --git a/backend/src/modules/order/entities/OrderMessage.ts b/backend/src/modules/order/entities/OrderMessage.ts new file mode 100644 index 0000000..bf0a3a0 --- /dev/null +++ b/backend/src/modules/order/entities/OrderMessage.ts @@ -0,0 +1,22 @@ +import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { OrderMessageSender } from '../types/OrderMessageSender'; +import { Order } from './Order'; + +@Entity('order_messages') +export class OrderMessage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => Order, order => order.messages, { onDelete: 'CASCADE' }) + @JoinColumn() + order: Order; + + @Column({ type: 'enum', enum: OrderMessageSender }) + sender: OrderMessageSender; + + @Column({ type: 'text' }) + body: string; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/modules/order/services/OrderAccessTokenService.spec.ts b/backend/src/modules/order/services/OrderAccessTokenService.spec.ts new file mode 100644 index 0000000..e2b22b8 --- /dev/null +++ b/backend/src/modules/order/services/OrderAccessTokenService.spec.ts @@ -0,0 +1,67 @@ +import { ConfigService } from '@nestjs/config'; +import { randomBytes } from 'node:crypto'; +import { EncryptionService } from '../../encryption/services/EncryptionService'; +import { OrderAccessTokenService } from './OrderAccessTokenService'; + +describe('OrderAccessTokenService', () => { + let service: OrderAccessTokenService; + + beforeEach(() => { + const encryptionService = new EncryptionService({ + get: jest.fn().mockReturnValue({ keyBase64: randomBytes(32).toString('base64') }) + } as unknown as ConfigService); + + service = new OrderAccessTokenService(encryptionService); + }); + + it('generates a formatted token with lookup and encrypted storage', () => { + const generated = service.generate(); + + expect(generated.token).toMatch(/^[0-9A-F]{8}(?:-[0-9A-F]{8}){3}$/); + expect(generated.lookup).toHaveLength(64); + expect(service.decryptStored(generated.encrypted)).toBe(generated.token); + }); + + it('sets lookup to the hash of the generated token', () => { + const generated = service.generate(); + + expect(service.hashLookup(generated.token)).toBe(generated.lookup); + }); + + it('generates distinct tokens on each call', () => { + const first = service.generate(); + const second = service.generate(); + + expect(first.token).not.toBe(second.token); + expect(first.lookup).not.toBe(second.lookup); + }); + + it('does not store the plaintext token in the encrypted blob', () => { + const generated = service.generate(); + const normalized = generated.token.replace(/-/g, ''); + + expect(generated.encrypted).not.toContain(generated.token); + expect(generated.encrypted).not.toContain(normalized); + }); + + it('supports auth lookup from a decrypted stored token', () => { + const generated = service.generate(); + const fromStorage = service.decryptStored(generated.encrypted); + + expect(service.hashLookup(fromStorage)).toBe(generated.lookup); + }); + + it('hashes lookup deterministically regardless of dashes and case', () => { + const lookupA = service.hashLookup('12345678-90ABCDEF-12345678-90ABCDEF'); + const lookupB = service.hashLookup('1234567890abcdef1234567890abcdef'); + + expect(lookupA).toBe(lookupB); + }); + + it('hashes different tokens to different lookups', () => { + const lookupA = service.hashLookup('12345678901234567890123456789012'); + const lookupB = service.hashLookup('FEDCBA0987654321FEDCBA0987654321'); + + expect(lookupA).not.toBe(lookupB); + }); +}); diff --git a/backend/src/modules/order/services/OrderAccessTokenService.ts b/backend/src/modules/order/services/OrderAccessTokenService.ts new file mode 100644 index 0000000..be6a6e0 --- /dev/null +++ b/backend/src/modules/order/services/OrderAccessTokenService.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { createHash, randomBytes } from 'node:crypto'; +import { EncryptionService } from '../../encryption/services/EncryptionService'; +import type { GeneratedAccessToken } from '../types/GeneratedAccessToken'; + +@Injectable() +export class OrderAccessTokenService { + private readonly tokenByteLength = 16; + private readonly tokenGroupLength = 8; + + constructor(private readonly encryptionService: EncryptionService) {} + + generate(): GeneratedAccessToken { + const normalized = randomBytes(this.tokenByteLength).toString('hex').toUpperCase(); + const token = this.formatToken(normalized); + + return { + token, + lookup: this.hashLookup(token), + encrypted: this.encryptToken(token) + }; + } + + private formatToken(normalized: string): string { + const groups = normalized.match(new RegExp(`.{1,${this.tokenGroupLength}}`, 'g')); + + if (!groups || groups.length !== 4) { + throw new Error('Invalid normalized access token length'); + } + + return groups.join('-'); + } + + hashLookup(token: string): string { + const normalized = this.normalizeToken(token); + + return createHash('sha256').update(normalized, 'utf8').digest('hex'); + } + + private encryptToken(token: string): string { + const normalized = this.normalizeToken(token); + + return this.encryptionService.encryptPlaintext(normalized); + } + + private normalizeToken(token: string): string { + return token.replace(/-/g, '').toUpperCase(); + } + + decryptStored(storedAccessToken: string): string { + const normalized = this.encryptionService.decryptPlaintext(storedAccessToken); + + return this.formatToken(normalized); + } +} diff --git a/backend/src/modules/order/services/OrderChatService.spec.ts b/backend/src/modules/order/services/OrderChatService.spec.ts new file mode 100644 index 0000000..96edfff --- /dev/null +++ b/backend/src/modules/order/services/OrderChatService.spec.ts @@ -0,0 +1,201 @@ +import { NotFoundException } from '@nestjs/common'; +import type { Repository } from 'typeorm'; +import type { EncryptionService } from '../../encryption/services/EncryptionService'; +import type { NotificationService } from '../../notifications/services/NotificationService'; +import type { Order } from '../entities/Order'; +import type { OrderMessage } from '../entities/OrderMessage'; +import { OrderChatService } from './OrderChatService'; +import { OrderMessageSender } from '../types/OrderMessageSender'; + +describe('OrderChatService', () => { + let orderRepo: { + exists: jest.Mock; + update: jest.Mock; + }; + let messageRepo: { + find: jest.Mock; + findOne: jest.Mock; + create: jest.Mock; + insert: jest.Mock; + delete: jest.Mock; + }; + let encryptionService: jest.Mocked< + Pick + >; + let notificationService: jest.Mocked>; + let service: OrderChatService; + + const decryptedMessages: OrderMessage[] = [ + { + id: 'message-1', + sender: OrderMessageSender.Buyer, + body: 'Hello there', + createdAt: new Date('2026-01-01T12:00:00.000Z') + } as OrderMessage + ]; + + beforeEach(() => { + orderRepo = { + exists: jest.fn().mockResolvedValue(true), + update: jest.fn().mockResolvedValue({ affected: 1 }) + }; + + messageRepo = { + find: jest.fn().mockResolvedValue([ + { + id: 'message-1', + sender: OrderMessageSender.Buyer, + body: 'serialized-body', + createdAt: new Date('2026-01-01T12:00:00.000Z') + } + ]), + findOne: jest.fn(), + create: jest.fn( + (entity): OrderMessage => + ({ + id: 'message-2', + createdAt: new Date('2026-01-02T12:00:00.000Z'), + ...entity + }) as OrderMessage + ), + insert: jest.fn(), + delete: jest.fn() + }; + + encryptionService = { + encryptPlaintext: jest.fn().mockReturnValue('serialized-body'), + decryptPlaintext: jest.fn().mockReturnValue('Hello there'), + decryptPlaintextFieldInPlace: jest.fn((messages, field) => { + for (const message of messages ?? []) { + (message as Record)[field as string] = 'Hello there'; + } + }) + }; + + notificationService = { + sendNotification: jest.fn().mockResolvedValue(undefined) + }; + + service = new OrderChatService( + orderRepo as unknown as Repository, + messageRepo as unknown as Repository, + encryptionService as unknown as EncryptionService, + notificationService as unknown as NotificationService + ); + }); + + it('creates a message and returns the full decrypted thread', async () => { + await expect(service.createMessage('order-1', OrderMessageSender.Buyer, 'Hello there')).resolves.toEqual( + decryptedMessages + ); + + expect(orderRepo.exists).toHaveBeenCalledWith({ where: { id: 'order-1' } }); + expect(messageRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + order: { id: 'order-1' }, + sender: OrderMessageSender.Buyer, + body: 'serialized-body' + }) + ); + expect(messageRepo.insert).toHaveBeenCalled(); + expect(notificationService.sendNotification).toHaveBeenCalledWith('order-1', 'newBuyerMessage'); + expect(messageRepo.find).toHaveBeenCalledWith({ + where: { order: { id: 'order-1' } }, + order: { createdAt: 'ASC' } + }); + }); + + it('throws when creating a message for a missing order', async () => { + orderRepo.exists.mockResolvedValue(false); + + await expect(service.createMessage('order-1', OrderMessageSender.Buyer, 'Hello there')).rejects.toBeInstanceOf( + NotFoundException + ); + + expect(messageRepo.insert).not.toHaveBeenCalled(); + expect(notificationService.sendNotification).not.toHaveBeenCalled(); + }); + + it('deletes a message and returns the full decrypted thread', async () => { + messageRepo.findOne.mockResolvedValue({ + id: 'message-1', + sender: OrderMessageSender.Buyer + }); + + await expect(service.deleteMessage('order-1', 'message-1', OrderMessageSender.Buyer)).resolves.toEqual( + decryptedMessages + ); + + expect(messageRepo.delete).toHaveBeenCalledWith('message-1'); + expect(messageRepo.find).toHaveBeenCalledWith({ + where: { order: { id: 'order-1' } }, + order: { createdAt: 'ASC' } + }); + }); + + it('throws when deleting a missing message', async () => { + messageRepo.findOne.mockResolvedValue(null); + + await expect(service.deleteMessage('order-1', 'message-1', OrderMessageSender.Buyer)).rejects.toBeInstanceOf( + NotFoundException + ); + }); + + it('counts unread buyer messages after staffChatLastReadAt', () => { + const readAt = new Date('2026-01-02T12:00:00.000Z'); + + expect( + service.countUnreadBuyerMessages({ + staffChatLastReadAt: readAt, + messages: [ + { + sender: OrderMessageSender.Buyer, + createdAt: new Date('2026-01-01T12:00:00.000Z') + } as OrderMessage, + { + sender: OrderMessageSender.Buyer, + createdAt: new Date('2026-01-03T12:00:00.000Z') + } as OrderMessage, + { + sender: OrderMessageSender.Staff, + createdAt: new Date('2026-01-04T12:00:00.000Z') + } as OrderMessage + ] + }) + ).toBe(1); + }); + + it('counts all buyer messages as unread when staffChatLastReadAt is null', () => { + expect( + service.countUnreadBuyerMessages({ + staffChatLastReadAt: null, + messages: [ + { + sender: OrderMessageSender.Buyer, + createdAt: new Date('2026-01-01T12:00:00.000Z') + } as OrderMessage + ] + }) + ).toBe(1); + }); + + it('marks chat as read for an existing order', async () => { + await service.markChatRead('order-1'); + + expect(orderRepo.exists).toHaveBeenCalledWith({ where: { id: 'order-1' } }); + expect(orderRepo.update).toHaveBeenCalledTimes(1); + + const [orderId, payload] = orderRepo.update.mock.calls[0] as [string, { staffChatLastReadAt: Date }]; + + expect(orderId).toBe('order-1'); + expect(payload.staffChatLastReadAt).toBeInstanceOf(Date); + }); + + it('throws when marking chat read for a missing order', async () => { + orderRepo.exists.mockResolvedValue(false); + + await expect(service.markChatRead('order-1')).rejects.toBeInstanceOf(NotFoundException); + + expect(orderRepo.update).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/modules/order/services/OrderChatService.ts b/backend/src/modules/order/services/OrderChatService.ts new file mode 100644 index 0000000..dc33ab5 --- /dev/null +++ b/backend/src/modules/order/services/OrderChatService.ts @@ -0,0 +1,90 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import dayjs from '../../../plugins/dayjs'; +import { EncryptionService } from '../../encryption/services/EncryptionService'; +import { NotificationService } from '../../notifications/services/NotificationService'; +import { Order } from '../entities/Order'; +import { OrderMessage } from '../entities/OrderMessage'; +import { OrderMessageSender } from '../types/OrderMessageSender'; + +@Injectable() +export class OrderChatService { + constructor( + @InjectRepository(Order) + private readonly orderRepo: Repository, + @InjectRepository(OrderMessage) + private readonly messageRepo: Repository, + private readonly encryptionService: EncryptionService, + private readonly notificationService: NotificationService + ) {} + + async listMessagesForOrder(orderId: string): Promise { + const messages = await this.messageRepo.find({ + where: { order: { id: orderId } }, + order: { createdAt: 'ASC' } + }); + + this.encryptionService.decryptPlaintextFieldInPlace(messages, 'body'); + + return messages; + } + + countUnreadBuyerMessages(order: Pick): number { + const readAfter = order.staffChatLastReadAt ? dayjs(order.staffChatLastReadAt) : dayjs(0); + + return (order.messages ?? []).filter( + message => message.sender === OrderMessageSender.Buyer && dayjs(message.createdAt).isAfter(readAfter) + ).length; + } + + async markChatRead(orderId: string): Promise { + const orderExists = await this.orderRepo.exists({ where: { id: orderId } }); + + if (!orderExists) { + throw new NotFoundException('Order not found'); + } + + await this.orderRepo.update(orderId, { staffChatLastReadAt: new Date() }); + } + + async createMessage(orderId: string, sender: OrderMessageSender, body: string): Promise { + const orderExists = await this.orderRepo.exists({ where: { id: orderId } }); + + if (!orderExists) { + throw new NotFoundException('Order not found'); + } + + const entity = this.messageRepo.create({ + order: { id: orderId }, + sender, + body: this.encryptionService.encryptPlaintext(body) + }); + + await this.messageRepo.insert(entity); + + if (sender === OrderMessageSender.Buyer) { + this.notificationService.sendNotification(orderId, 'newBuyerMessage'); + } + + return this.listMessagesForOrder(orderId); + } + + async deleteMessage(orderId: string, messageId: string, sender: OrderMessageSender): Promise { + const message = await this.messageRepo.findOne({ + where: { + id: messageId, + order: { id: orderId }, + sender + } + }); + + if (!message) { + throw new NotFoundException('We could not find that message.'); + } + + await this.messageRepo.delete(message.id); + + return this.listMessagesForOrder(orderId); + } +} diff --git a/backend/src/modules/order/services/OrderClaimService.spec.ts b/backend/src/modules/order/services/OrderClaimService.spec.ts new file mode 100644 index 0000000..4c1b500 --- /dev/null +++ b/backend/src/modules/order/services/OrderClaimService.spec.ts @@ -0,0 +1,541 @@ +import type { EntityManager } from 'typeorm'; +import { In, MoreThanOrEqual } from 'typeorm'; +import { DeliveryMode } from '../../product/types/DeliveryMode'; +import { DigitalStockItem } from '../../product/entities/DigitalStockItem'; +import type { CheckoutSessionLine } from '../../storefrontCheckout/entities/CheckoutSessionLine'; +import { DiscountCode } from '../../discountCode/entities/DiscountCode'; +import { ProductVariant } from '../../product/entities/ProductVariant'; +import { OrderFailureReason } from '../types/OrderFailureReason'; +import type { DigitalStockItemRepoMock, DiscountCodeRepoMock, VariantRepoMock } from '../types/OrderClaimServiceMocks'; +import type { CheckoutSession } from '../../storefrontCheckout/entities/CheckoutSession'; +import { OrderClaimService } from './OrderClaimService'; + +const buildAutoLine = (overrides: Partial = {}): CheckoutSessionLine => + ({ + id: 'line-auto-1', + variantId: 'variant-auto-1', + qty: 1, + deliveryMode: DeliveryMode.Auto, + ...overrides + }) as unknown as CheckoutSessionLine; + +const buildManualLine = (overrides: Partial = {}): CheckoutSessionLine => + ({ + id: 'line-manual-1', + variantId: 'variant-manual-1', + qty: 1, + deliveryMode: DeliveryMode.Manual, + ...overrides + }) as unknown as CheckoutSessionLine; + +const mockDigitalStockItemsLoad = ( + digitalStockItemRepo: DigitalStockItemRepoMock, + items: Array<{ id: string; content: string; attachments: unknown[] }>, + options: { hydratedItems?: Array<{ id: string; content: string; attachments: unknown[] }> } = {} +) => { + const hydratedItems = options.hydratedItems ?? items; + + const idQueryBuilder = { + select: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + setLock: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue(items.map(item => ({ id: item.id }))) + }; + + const loadQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(hydratedItems) + }; + + let callCount = 0; + + digitalStockItemRepo.createQueryBuilder.mockImplementation(() => { + callCount += 1; + + return callCount === 1 ? idQueryBuilder : loadQueryBuilder; + }); + + return { idQueryBuilder, loadQueryBuilder }; +}; + +describe('OrderClaimService', () => { + let service: OrderClaimService; + let digitalStockItemRepo: DigitalStockItemRepoMock; + let variantRepo: VariantRepoMock; + let discountCodeRepo: DiscountCodeRepoMock; + let manager: EntityManager; + + beforeEach(() => { + digitalStockItemRepo = { + find: jest.fn(), + update: jest.fn(), + createQueryBuilder: jest.fn() + }; + variantRepo = { + findOne: jest.fn(), + update: jest.fn() + }; + discountCodeRepo = { + findOne: jest.fn(), + update: jest.fn() + }; + + manager = { + getRepository: jest.fn((entity: { name: string }) => { + if (entity.name === DigitalStockItem.name) { + return digitalStockItemRepo; + } + + if (entity.name === ProductVariant.name) { + return variantRepo; + } + + if (entity.name === DiscountCode.name) { + return discountCodeRepo; + } + + throw new Error(`Unexpected repository: ${entity.name}`); + }) + } as unknown as EntityManager; + + service = new OrderClaimService(); + }); + + describe('auto-delivery lines', () => { + it('claims stock and returns digital stock claims', async () => { + mockDigitalStockItemsLoad(digitalStockItemRepo, [ + { + id: 'item-1', + content: 'username: buyer\npassword: secret', + attachments: [] + }, + { + id: 'item-2', + content: 'license: ABC-123', + attachments: [ + { + id: 'stock-attachment-1', + storageKey: 'attachments/item-2/file.pdf', + originalFilename: 'file.pdf', + mimeType: 'application/pdf', + sizeBytes: 1024 + } + ] + } + ]); + + const line = buildAutoLine({ id: 'line-1', variantId: 'variant-1', qty: 2 }); + + const result = await service.claimFromSession(manager, { + lines: [line], + discounts: [] + } as unknown as CheckoutSession); + + expect(result).toEqual({ + success: true, + stockClaims: [ + { + checkoutSessionLineId: 'line-1', + items: [ + { + id: 'item-1', + content: 'username: buyer\npassword: secret', + attachments: [] + }, + { + id: 'item-2', + content: 'license: ABC-123', + attachments: [ + { + id: 'stock-attachment-1', + storageKey: 'attachments/item-2/file.pdf', + originalFilename: 'file.pdf', + mimeType: 'application/pdf', + sizeBytes: 1024 + } + ] + } + ] + } + ] + }); + expect(digitalStockItemRepo.createQueryBuilder).toHaveBeenCalled(); + expect(digitalStockItemRepo.update).toHaveBeenCalledWith( + { id: In(['item-1', 'item-2']) }, + { isSold: true } + ); + }); + + it('returns stock unavailable when locked stock is insufficient', async () => { + mockDigitalStockItemsLoad(digitalStockItemRepo, [{ id: 'item-1', content: '', attachments: [] }]); + + const line = buildAutoLine({ qty: 2 }); + + const result = await service.claimFromSession(manager, { + lines: [line], + discounts: [] + } as unknown as CheckoutSession); + + expect(result).toEqual({ + success: false, + failureReason: OrderFailureReason.StockUnavailable + }); + expect(digitalStockItemRepo.createQueryBuilder).toHaveBeenCalledTimes(1); + expect(digitalStockItemRepo.update).not.toHaveBeenCalled(); + }); + + it('locks item ids with delivery mode check, then hydrates by id without attachments', async () => { + const { idQueryBuilder, loadQueryBuilder } = mockDigitalStockItemsLoad(digitalStockItemRepo, [ + { id: 'item-1', content: 'key', attachments: [] } + ]); + + const line = buildAutoLine({ variantId: 'variant-1', qty: 1 }); + + await service.claimFromSession(manager, { + lines: [line], + discounts: [] + } as unknown as CheckoutSession); + + expect(idQueryBuilder.select).toHaveBeenCalledWith('item.id', 'id'); + expect(idQueryBuilder.innerJoin).toHaveBeenCalledWith('item.variant', 'variant'); + expect(idQueryBuilder.innerJoin).toHaveBeenCalledWith('variant.product', 'product'); + expect(idQueryBuilder.where).toHaveBeenCalledWith('variant.id = :variantId', { + variantId: 'variant-1' + }); + expect(idQueryBuilder.andWhere).toHaveBeenCalledWith('product.deliveryMode = :deliveryMode', { + deliveryMode: DeliveryMode.Auto + }); + expect(idQueryBuilder.andWhere).toHaveBeenCalledWith('item.isSold = false'); + expect(idQueryBuilder.limit).toHaveBeenCalledWith(1); + expect(idQueryBuilder.setLock).toHaveBeenCalledWith('pessimistic_write', undefined, ['item']); + expect(idQueryBuilder.getRawMany).toHaveBeenCalled(); + expect(loadQueryBuilder.leftJoinAndSelect).toHaveBeenCalledWith('item.attachments', 'attachment'); + expect(loadQueryBuilder.where).toHaveBeenCalledWith('item.id IN (:...ids)', { ids: ['item-1'] }); + expect(loadQueryBuilder.getMany).toHaveBeenCalled(); + }); + + it('claims full qty when each item has multiple attachments', async () => { + const attachment = { + id: 'attachment-1', + storageKey: 'attachments/item/file.pdf', + originalFilename: 'file.pdf', + mimeType: 'application/pdf', + sizeBytes: 512 + }; + + mockDigitalStockItemsLoad(digitalStockItemRepo, [ + { id: 'item-1', content: 'line-1', attachments: [attachment, { ...attachment, id: 'attachment-2' }] }, + { id: 'item-2', content: 'line-2', attachments: [attachment, { ...attachment, id: 'attachment-3' }] } + ]); + + const line = buildAutoLine({ qty: 2 }); + + const result = await service.claimFromSession(manager, { + lines: [line], + discounts: [] + } as unknown as CheckoutSession); + + expect(result).toEqual({ + success: true, + stockClaims: [ + { + checkoutSessionLineId: 'line-auto-1', + items: [ + { + id: 'item-1', + content: 'line-1', + attachments: [attachment, { ...attachment, id: 'attachment-2' }] + }, + { + id: 'item-2', + content: 'line-2', + attachments: [attachment, { ...attachment, id: 'attachment-3' }] + } + ] + } + ] + }); + expect(digitalStockItemRepo.update).toHaveBeenCalledWith( + { id: In(['item-1', 'item-2']) }, + { isSold: true } + ); + }); + + it('returns stock unavailable when lock returns more ids than qty', async () => { + const { idQueryBuilder } = mockDigitalStockItemsLoad(digitalStockItemRepo, [ + { id: 'item-1', content: '', attachments: [] }, + { id: 'item-2', content: '', attachments: [] }, + { id: 'item-3', content: '', attachments: [] } + ]); + idQueryBuilder.getRawMany.mockResolvedValue([ + { id: 'item-1' }, + { id: 'item-2' }, + { id: 'item-3' } + ]); + + const line = buildAutoLine({ qty: 2 }); + + const result = await service.claimFromSession(manager, { + lines: [line], + discounts: [] + } as unknown as CheckoutSession); + + expect(result).toEqual({ + success: false, + failureReason: OrderFailureReason.StockUnavailable + }); + expect(digitalStockItemRepo.createQueryBuilder).toHaveBeenCalledTimes(1); + expect(digitalStockItemRepo.update).not.toHaveBeenCalled(); + }); + + it('returns stock unavailable when hydrate returns fewer items than qty', async () => { + mockDigitalStockItemsLoad( + digitalStockItemRepo, + [ + { id: 'item-1', content: '', attachments: [] }, + { id: 'item-2', content: '', attachments: [] } + ], + { + hydratedItems: [{ id: 'item-1', content: '', attachments: [] }] + } + ); + + const line = buildAutoLine({ qty: 2 }); + + const result = await service.claimFromSession(manager, { + lines: [line], + discounts: [] + } as unknown as CheckoutSession); + + expect(result).toEqual({ + success: false, + failureReason: OrderFailureReason.StockUnavailable + }); + expect(digitalStockItemRepo.createQueryBuilder).toHaveBeenCalledTimes(2); + expect(digitalStockItemRepo.update).not.toHaveBeenCalled(); + }); + }); + + describe('manual-delivery lines', () => { + it('returns manual stock claims and decrements variant stock', async () => { + variantRepo.findOne.mockResolvedValue({ id: 'variant-manual-1', stockQuantity: 5 }); + + const line = buildManualLine({ qty: 2 }); + + const result = await service.claimFromSession(manager, { + lines: [line], + discounts: [] + } as unknown as CheckoutSession); + + expect(result).toEqual({ + success: true, + stockClaims: [ + { + checkoutSessionLineId: 'line-manual-1', + variantId: 'variant-manual-1', + newStockQuantity: 3 + } + ] + }); + expect(variantRepo.findOne).toHaveBeenCalledWith({ + where: { + id: 'variant-manual-1', + product: { deliveryMode: DeliveryMode.Manual }, + stockQuantity: MoreThanOrEqual(2) + }, + lock: { mode: 'pessimistic_write' } + }); + expect(variantRepo.update).toHaveBeenCalledWith('variant-manual-1', { stockQuantity: 3 }); + }); + + it('returns stock unavailable when manual variant stock is insufficient', async () => { + variantRepo.findOne.mockResolvedValue(null); + + const line = buildManualLine(); + + const result = await service.claimFromSession(manager, { + lines: [line], + discounts: [] + } as unknown as CheckoutSession); + + expect(result).toEqual({ + success: false, + failureReason: OrderFailureReason.StockUnavailable + }); + expect(variantRepo.update).not.toHaveBeenCalled(); + }); + }); + + describe('discount redeems', () => { + it('increments discount redemption count after stock is prepared', async () => { + variantRepo.findOne.mockResolvedValue({ id: 'variant-manual-1', stockQuantity: 5 }); + discountCodeRepo.findOne.mockResolvedValue({ + id: 'discount-1', + code: 'SAVE10', + redemptionCount: 2, + maxRedemptions: 10 + }); + + const line = buildManualLine(); + + const result = await service.claimFromSession(manager, { + lines: [line], + discounts: [{ code: 'SAVE10' }] + } as unknown as CheckoutSession); + + expect(result.success).toBe(true); + expect(discountCodeRepo.findOne).toHaveBeenCalledWith({ + where: { code: 'SAVE10' }, + lock: { mode: 'pessimistic_write' } + }); + expect(discountCodeRepo.update).toHaveBeenCalledWith('discount-1', { redemptionCount: 3 }); + }); + + it('returns discount exhausted when the code is missing', async () => { + variantRepo.findOne.mockResolvedValue({ id: 'variant-manual-1', stockQuantity: 5 }); + discountCodeRepo.findOne.mockResolvedValue(null); + + const line = buildManualLine(); + + const result = await service.claimFromSession(manager, { + lines: [line], + discounts: [{ code: 'MISSING' }] + } as unknown as CheckoutSession); + + expect(result).toEqual({ + success: false, + failureReason: OrderFailureReason.DiscountExhausted + }); + expect(variantRepo.update).not.toHaveBeenCalled(); + expect(discountCodeRepo.update).not.toHaveBeenCalled(); + }); + + it('returns discount exhausted when the code reached its redemption limit', async () => { + variantRepo.findOne.mockResolvedValue({ id: 'variant-manual-1', stockQuantity: 5 }); + discountCodeRepo.findOne.mockResolvedValue({ + id: 'discount-1', + code: 'MAXED', + redemptionCount: 5, + maxRedemptions: 5 + }); + + const line = buildManualLine(); + + const result = await service.claimFromSession(manager, { + lines: [line], + discounts: [{ code: 'MAXED' }] + } as unknown as CheckoutSession); + + expect(result).toEqual({ + success: false, + failureReason: OrderFailureReason.DiscountExhausted + }); + expect(discountCodeRepo.update).not.toHaveBeenCalled(); + }); + }); + + describe('session validation and mixed carts', () => { + it('returns stock unavailable when the session has no lines', async () => { + const result = await service.claimFromSession(manager, { + lines: [], + discounts: [] + } as unknown as CheckoutSession); + + expect(result).toEqual({ + success: false, + failureReason: OrderFailureReason.StockUnavailable + }); + expect(digitalStockItemRepo.createQueryBuilder).not.toHaveBeenCalled(); + expect(variantRepo.findOne).not.toHaveBeenCalled(); + }); + + it('claims stock for mixed manual and auto lines in one session', async () => { + variantRepo.findOne.mockResolvedValue({ id: 'variant-manual-1', stockQuantity: 4 }); + mockDigitalStockItemsLoad(digitalStockItemRepo, [ + { + id: 'item-1', + content: 'license-key', + attachments: [] + } + ]); + + const manualLine = buildManualLine(); + const autoLine = buildAutoLine({ id: 'line-auto-2', variantId: 'variant-auto-2' }); + + const result = await service.claimFromSession(manager, { + lines: [autoLine, manualLine], + discounts: [] + } as unknown as CheckoutSession); + + expect(result).toEqual({ + success: true, + stockClaims: [ + { + checkoutSessionLineId: 'line-auto-2', + items: [ + { + id: 'item-1', + content: 'license-key', + attachments: [] + } + ] + }, + { + checkoutSessionLineId: 'line-manual-1', + variantId: 'variant-manual-1', + newStockQuantity: 3 + } + ] + }); + expect(variantRepo.update).toHaveBeenCalledWith('variant-manual-1', { stockQuantity: 3 }); + expect(digitalStockItemRepo.update).toHaveBeenCalled(); + }); + + it('does not apply stock when a later line fails preparation', async () => { + variantRepo.findOne.mockResolvedValue({ id: 'variant-manual-1', stockQuantity: 5 }); + mockDigitalStockItemsLoad(digitalStockItemRepo, []); + + const manualLine = buildManualLine(); + const autoLine = buildAutoLine({ id: 'line-auto-2', variantId: 'variant-auto-2' }); + + const result = await service.claimFromSession(manager, { + lines: [manualLine, autoLine], + discounts: [] + } as unknown as CheckoutSession); + + expect(result).toEqual({ + success: false, + failureReason: OrderFailureReason.StockUnavailable + }); + expect(variantRepo.update).not.toHaveBeenCalled(); + expect(digitalStockItemRepo.update).not.toHaveBeenCalled(); + }); + + it('does not apply stock when discount preparation fails after stock prepared', async () => { + variantRepo.findOne.mockResolvedValue({ id: 'variant-manual-1', stockQuantity: 5 }); + discountCodeRepo.findOne.mockResolvedValue(null); + + const line = buildManualLine(); + + const result = await service.claimFromSession(manager, { + lines: [line], + discounts: [{ code: 'MISSING' }] + } as unknown as CheckoutSession); + + expect(result).toEqual({ + success: false, + failureReason: OrderFailureReason.DiscountExhausted + }); + expect(variantRepo.update).not.toHaveBeenCalled(); + expect(digitalStockItemRepo.update).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/modules/order/services/OrderClaimService.ts b/backend/src/modules/order/services/OrderClaimService.ts new file mode 100644 index 0000000..39ddfc7 --- /dev/null +++ b/backend/src/modules/order/services/OrderClaimService.ts @@ -0,0 +1,187 @@ +import { Injectable } from '@nestjs/common'; +import type { EntityManager } from 'typeorm'; +import { In, MoreThanOrEqual } from 'typeorm'; +import { DiscountCode } from '../../discountCode/entities/DiscountCode'; +import { DeliveryMode } from '../../product/types/DeliveryMode'; +import { DigitalStockItem } from '../../product/entities/DigitalStockItem'; +import { ProductVariant } from '../../product/entities/ProductVariant'; +import { getRedemptionLimitIssue } from '../../storefrontCart/utils/getRedemptionLimitIssue'; +import type { CheckoutSession } from '../../storefrontCheckout/entities/CheckoutSession'; +import type { CheckoutSessionLine } from '../../storefrontCheckout/entities/CheckoutSessionLine'; +import type { ClaimFromSessionResult } from '../types/ClaimFromSessionResult'; +import { OrderFailureReason } from '../types/OrderFailureReason'; +import type { PreparedDiscountRedeem } from '../types/PreparedDiscountRedeem'; +import type { PreparedStockClaim } from '../types/PreparedStockClaim'; + +@Injectable() +export class OrderClaimService { + async claimFromSession( + manager: EntityManager, + { lines, discounts }: CheckoutSession + ): Promise { + if (lines.length === 0) { + return { success: false, failureReason: OrderFailureReason.StockUnavailable }; + } + + const stockClaims: PreparedStockClaim[] = []; + const antiDeadlockSortedLines = [...lines].sort((a, b) => a.variantId.localeCompare(b.variantId)); + + for (const line of antiDeadlockSortedLines) { + const prepared = await this.prepareStockClaim(manager, line); + + if (!prepared) { + return { success: false, failureReason: OrderFailureReason.StockUnavailable }; + } + + stockClaims.push(prepared); + } + + const discountRedeems: PreparedDiscountRedeem[] = []; + const antiDeadlockSortedDiscounts = [...discounts].sort((a, b) => a.code.localeCompare(b.code)); + + for (const discount of antiDeadlockSortedDiscounts) { + const prepared = await this.prepareDiscountRedeem(manager, discount.code); + + if (!prepared) { + return { success: false, failureReason: OrderFailureReason.DiscountExhausted }; + } + + discountRedeems.push(prepared); + } + + await this.applyStockClaims(manager, stockClaims); + await this.applyDiscountRedeems(manager, discountRedeems); + + return { success: true, stockClaims }; + } + + /** + * Auto-delivery: lock the oldest unsold digital stock rows first (IDs only), then load content + * and attachments in a second query. + * + * The lock query inner-joins variant/product (many-to-one) to verify deliveryMode at claim + * time. With getRawMany(), use limit() when joins are present — take()/skip() target entity + * pagination and are omitted from SQL on raw queries with joins. Do not join one-to-many + * relations (attachments) in the limited query; row multiplication returns fewer parents than + * qty. + * + * @see https://github.com/typeorm/typeorm/issues/11590#issuecomment-3166485348 + * @see https://github.com/typeorm/typeorm/issues/11316#issuecomment-2074916139 + */ + private async prepareStockClaim( + manager: EntityManager, + line: CheckoutSessionLine + ): Promise { + const variantRepo = manager.getRepository(ProductVariant); + const digitalStockItemRepo = manager.getRepository(DigitalStockItem); + + if (line.deliveryMode === DeliveryMode.Manual) { + const variant = await variantRepo.findOne({ + where: { + id: line.variantId, + product: { deliveryMode: DeliveryMode.Manual }, + stockQuantity: MoreThanOrEqual(line.qty) + }, + lock: { mode: 'pessimistic_write' } + }); + + if (!variant) { + return null; + } + + return { + checkoutSessionLineId: line.id, + variantId: variant.id, + newStockQuantity: variant.stockQuantity! - line.qty + }; + } + + const digitalStockItemIds = await digitalStockItemRepo + .createQueryBuilder('item') + .select('item.id', 'id') + .innerJoin('item.variant', 'variant') + .innerJoin('variant.product', 'product') + .where('variant.id = :variantId', { variantId: line.variantId }) + .andWhere('product.deliveryMode = :deliveryMode', { deliveryMode: DeliveryMode.Auto }) + .andWhere('item.isSold = false') + .orderBy('item.createdAt', 'ASC') + .limit(line.qty) + .setLock('pessimistic_write', undefined, ['item']) + .getRawMany<{ id: string }>(); + + if (digitalStockItemIds.length !== line.qty) { + return null; + } + + const ids = digitalStockItemIds.map(row => row.id); + + const digitalStockItemsWithRelations = await digitalStockItemRepo + .createQueryBuilder('item') + .leftJoinAndSelect('item.attachments', 'attachment') + .addSelect('item.content') + .addSelect('attachment.storageKey') + .where('item.id IN (:...ids)', { ids }) + .orderBy('item.createdAt', 'ASC') + .addOrderBy('attachment.createdAt', 'ASC') + .getMany(); + + if (digitalStockItemsWithRelations.length !== line.qty) { + return null; + } + + return { + checkoutSessionLineId: line.id, + items: digitalStockItemsWithRelations + }; + } + + private async prepareDiscountRedeem(manager: EntityManager, code: string): Promise { + const discountCodeRepo = manager.getRepository(DiscountCode); + + const discountCode = await discountCodeRepo.findOne({ + where: { code }, + lock: { mode: 'pessimistic_write' } + }); + + if (!discountCode) { + return null; + } + + const issue = getRedemptionLimitIssue(discountCode.redemptionCount, discountCode.maxRedemptions); + + if (issue) { + return null; + } + + return { + discountCodeId: discountCode.id, + newRedemptionCount: discountCode.redemptionCount + 1 + }; + } + + private async applyStockClaims(manager: EntityManager, stockClaims: PreparedStockClaim[]): Promise { + const variantRepo = manager.getRepository(ProductVariant); + const digitalStockItemRepo = manager.getRepository(DigitalStockItem); + + for (const claim of stockClaims) { + if ('newStockQuantity' in claim) { + await variantRepo.update(claim.variantId, { stockQuantity: claim.newStockQuantity }); + } else { + await digitalStockItemRepo.update({ id: In(claim.items.map(item => item.id)) }, { isSold: true }); + } + } + } + + private async applyDiscountRedeems( + manager: EntityManager, + discountRedeems: PreparedDiscountRedeem[] + ): Promise { + const discountCodeRepo = manager.getRepository(DiscountCode); + + for (const redeem of discountRedeems) { + await discountCodeRepo.update(redeem.discountCodeId, { + redemptionCount: redeem.newRedemptionCount + }); + } + } +} diff --git a/backend/src/modules/order/services/OrderCreationService.spec.ts b/backend/src/modules/order/services/OrderCreationService.spec.ts new file mode 100644 index 0000000..b53c376 --- /dev/null +++ b/backend/src/modules/order/services/OrderCreationService.spec.ts @@ -0,0 +1,417 @@ +import type { DataSource, EntityManager } from 'typeorm'; +import { DeliveryMode } from '../../product/types/DeliveryMode'; +import type { CheckoutSessionLine } from '../../storefrontCheckout/entities/CheckoutSessionLine'; +import { CheckoutSession } from '../../storefrontCheckout/entities/CheckoutSession'; +import type { Invoice } from '../../payment/entities/Invoice'; +import { PaymentMethod } from '../../payment/types/PaymentMethod'; +import type { NotificationService } from '../../notifications/services/NotificationService'; +import { Order } from '../entities/Order'; +import { OrderFailureReason } from '../types/OrderFailureReason'; +import { ManualLineFulfillmentStatus } from '../types/ManualLineFulfillmentStatus'; +import type { OrderAccessTokenService } from './OrderAccessTokenService'; +import type { OrderClaimService } from './OrderClaimService'; +import { OrderCreationService } from './OrderCreationService'; + +const buildPaidInvoice = () => ({ + id: 'invoice-1', + paymentMethod: PaymentMethod.Xmr, + expectedTotalAtomic: '1000', + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + moneroDetails: { requiredConfirmations: 1 }, + payments: [{ amountAtomic: '1000', confirmations: 1 }] +}); + +const buildManualLine = (overrides: Partial = {}): CheckoutSessionLine => + ({ + id: 'line-manual-1', + variantId: 'variant-manual-1', + productId: 'product-1', + productTitle: 'Manual product', + variantTitle: 'Manual variant', + thumbnailUrl: null, + qty: 1, + unitPriceFiat: 10, + lineSubtotalFiat: 10, + deliveryMode: DeliveryMode.Manual, + ...overrides + }) as CheckoutSessionLine; + +const buildAutoLine = (overrides: Partial = {}): CheckoutSessionLine => + ({ + id: 'line-auto-1', + variantId: 'variant-auto-1', + productId: 'product-2', + productTitle: 'Digital product', + variantTitle: 'Digital variant', + thumbnailUrl: '/thumb.png', + qty: 1, + unitPriceFiat: 20, + lineSubtotalFiat: 20, + deliveryMode: DeliveryMode.Auto, + ...overrides + }) as CheckoutSessionLine; + +const buildPaidSession = (overrides: Partial = {}): CheckoutSession => + ({ + id: 'session-1', + invoice: buildPaidInvoice(), + lines: [buildManualLine()], + discounts: [{ code: 'SAVE10', amountFiat: 5 }], + ...overrides + }) as CheckoutSession; + +describe('OrderCreationService', () => { + let service: OrderCreationService; + let dataSource: { + transaction: jest.Mock; + }; + let sessionQueryBuilder: { + leftJoinAndSelect: jest.Mock; + leftJoin: jest.Mock; + where: jest.Mock; + andWhere: jest.Mock; + setLock: jest.Mock; + getOne: jest.Mock; + }; + let sessionRepo: { + createQueryBuilder: jest.Mock; + }; + let orderRepo: { + create: jest.Mock; + save: jest.Mock; + }; + let manager: EntityManager; + let orderClaimService: { + claimFromSession: jest.Mock; + }; + let accessTokenService: { + generate: jest.Mock; + }; + let notificationService: { + sendNotification: jest.Mock; + }; + + beforeEach(() => { + sessionQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + setLock: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null) + }; + + sessionRepo = { + createQueryBuilder: jest.fn().mockReturnValue(sessionQueryBuilder) + }; + + orderRepo = { + create: jest.fn(data => ({ id: 'order-1', ...data })), + save: jest.fn(async order => order) + }; + + manager = { + getRepository: jest.fn((entity: { name: string }) => { + if (entity.name === CheckoutSession.name) { + return sessionRepo; + } + + if (entity.name === Order.name) { + return orderRepo; + } + + throw new Error(`Unexpected repository: ${entity.name}`); + }) + } as unknown as EntityManager; + + dataSource = { + transaction: jest.fn(async (callback: (entityManager: EntityManager) => Promise) => callback(manager)) + }; + + orderClaimService = { + claimFromSession: jest.fn().mockResolvedValue({ + success: true, + stockClaims: [ + { + checkoutSessionLineId: 'line-manual-1', + variantId: 'variant-manual-1', + newStockQuantity: 4 + } + ] + }) + }; + + accessTokenService = { + generate: jest.fn().mockReturnValue({ + lookup: 'lookup-token', + encrypted: 'encrypted-token' + }) + }; + + notificationService = { + sendNotification: jest.fn() + }; + + service = new OrderCreationService( + dataSource as unknown as DataSource, + orderClaimService as unknown as OrderClaimService, + accessTokenService as unknown as OrderAccessTokenService, + notificationService as unknown as NotificationService + ); + }); + + it('does not create an order when the checkout session is missing', async () => { + sessionQueryBuilder.getOne.mockResolvedValue(null); + + await service.createFromPaidSession('session-1'); + + expect(orderRepo.save).not.toHaveBeenCalled(); + expect(notificationService.sendNotification).not.toHaveBeenCalled(); + }); + + it('only considers open sessions without an existing order and with a non-expired invoice', async () => { + sessionQueryBuilder.getOne.mockResolvedValue(null); + + await service.createFromPaidSession('session-1'); + + expect(sessionQueryBuilder.andWhere).toHaveBeenCalledWith('session.cancelledAt IS NULL'); + expect(sessionQueryBuilder.andWhere).toHaveBeenCalledWith('order.id IS NULL'); + expect(sessionQueryBuilder.andWhere).toHaveBeenCalledWith('invoice.expiresAt > :now', { + now: expect.any(Date) + }); + }); + + it('does not create an order when the session has no invoice', async () => { + sessionQueryBuilder.getOne.mockResolvedValue(buildPaidSession({ invoice: undefined })); + + await service.createFromPaidSession('session-1'); + + expect(orderRepo.save).not.toHaveBeenCalled(); + expect(notificationService.sendNotification).not.toHaveBeenCalled(); + }); + + it('does not create an order when the invoice is not paid sufficiently', async () => { + sessionQueryBuilder.getOne.mockResolvedValue( + buildPaidSession({ + invoice: { + ...buildPaidInvoice(), + payments: [{ amountAtomic: '100', confirmations: 1 }] + } as Invoice + }) + ); + + await service.createFromPaidSession('session-1'); + + expect(orderClaimService.claimFromSession).not.toHaveBeenCalled(); + expect(orderRepo.save).not.toHaveBeenCalled(); + expect(notificationService.sendNotification).not.toHaveBeenCalled(); + }); + + it('creates an order and sends a notification when the session is paid and stock is claimed', async () => { + const session = buildPaidSession(); + sessionQueryBuilder.getOne.mockResolvedValue(session); + + await service.createFromPaidSession('session-1'); + + expect(orderClaimService.claimFromSession).toHaveBeenCalledWith(manager, session); + expect(accessTokenService.generate).toHaveBeenCalled(); + expect(orderRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + accessTokenLookup: 'lookup-token', + accessToken: 'encrypted-token', + failureReason: null, + checkoutSession: { id: 'session-1' }, + checkoutInvoice: { id: 'invoice-1' }, + discounts: [{ code: 'SAVE10', amountFiat: 5 }], + lines: [ + expect.objectContaining({ + variantId: 'variant-manual-1', + manualFulfillment: { status: ManualLineFulfillmentStatus.Pending } + }) + ] + }) + ); + expect(orderRepo.save).toHaveBeenCalled(); + expect(notificationService.sendNotification).toHaveBeenCalledWith('order-1', 'newOrder'); + }); + + it('creates a failed order when stock claim fails', async () => { + sessionQueryBuilder.getOne.mockResolvedValue(buildPaidSession()); + orderClaimService.claimFromSession.mockResolvedValue({ + success: false, + failureReason: OrderFailureReason.StockUnavailable + }); + + await service.createFromPaidSession('session-1'); + + expect(orderRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + failureReason: OrderFailureReason.StockUnavailable, + lines: [ + expect.objectContaining({ + variantId: 'variant-manual-1' + }) + ] + }) + ); + expect(notificationService.sendNotification).toHaveBeenCalledWith('order-1', 'newOrder'); + }); + + + it('creates a failed order when discount redemption fails', async () => { + sessionQueryBuilder.getOne.mockResolvedValue(buildPaidSession()); + orderClaimService.claimFromSession.mockResolvedValue({ + success: false, + failureReason: OrderFailureReason.DiscountExhausted + }); + + await service.createFromPaidSession('session-1'); + + expect(orderRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + failureReason: OrderFailureReason.DiscountExhausted + }) + ); + expect(notificationService.sendNotification).toHaveBeenCalledWith('order-1', 'newOrder'); + }); + + it('maps mixed manual and auto lines in one order', async () => { + sessionQueryBuilder.getOne.mockResolvedValue( + buildPaidSession({ + lines: [buildManualLine(), buildAutoLine()] + }) + ); + orderClaimService.claimFromSession.mockResolvedValue({ + success: true, + stockClaims: [ + { + checkoutSessionLineId: 'line-manual-1', + variantId: 'variant-manual-1', + newStockQuantity: 4 + }, + { + checkoutSessionLineId: 'line-auto-1', + items: [{ id: 'stock-item-1', content: 'license-key', attachments: [] }] + } + ] + }); + + await service.createFromPaidSession('session-1'); + + expect(orderRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + lines: [ + expect.objectContaining({ + variantId: 'variant-manual-1', + manualFulfillment: { status: ManualLineFulfillmentStatus.Pending } + }), + expect.objectContaining({ + variantId: 'variant-auto-1', + autoFulfillmentItems: [ + expect.objectContaining({ + contentSnapshot: 'license-key', + attachments: [] + }) + ] + }) + ] + }) + ); + }); + + it('does not attach fulfillment when a successful claim does not match the line', async () => { + sessionQueryBuilder.getOne.mockResolvedValue( + buildPaidSession({ + lines: [buildAutoLine()] + }) + ); + orderClaimService.claimFromSession.mockResolvedValue({ + success: true, + stockClaims: [ + { + checkoutSessionLineId: 'other-line-id', + items: [{ id: 'stock-item-1', content: 'license-key', attachments: [] }] + } + ] + }); + + await service.createFromPaidSession('session-1'); + + const createdOrder = orderRepo.create.mock.calls[0][0]; + const autoLine = createdOrder.lines.find((line: { variantId: string }) => line.variantId === 'variant-auto-1'); + + expect(autoLine).toEqual( + expect.objectContaining({ + variantId: 'variant-auto-1' + }) + ); + expect(autoLine).not.toHaveProperty('autoFulfillmentItems'); + }); + + it('maps an empty discount list when the session has no discounts', async () => { + sessionQueryBuilder.getOne.mockResolvedValue(buildPaidSession({ discounts: undefined })); + + await service.createFromPaidSession('session-1'); + + expect(orderRepo.create).toHaveBeenCalledWith(expect.objectContaining({ discounts: [] })); + }); + + it('maps auto-delivery claims onto order lines with attachments', async () => { + sessionQueryBuilder.getOne.mockResolvedValue( + buildPaidSession({ + lines: [buildAutoLine()] + }) + ); + orderClaimService.claimFromSession.mockResolvedValue({ + success: true, + stockClaims: [ + { + checkoutSessionLineId: 'line-auto-1', + items: [ + { + id: 'stock-item-1', + content: 'license-key-123', + attachments: [ + { + id: 'attachment-1', + storageKey: 'stock/file.pdf', + originalFilename: 'file.pdf', + mimeType: 'application/pdf', + sizeBytes: 1024 + } + ] + } + ] + } + ] + }); + + await service.createFromPaidSession('session-1'); + + expect(orderRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + lines: [ + expect.objectContaining({ + variantId: 'variant-auto-1', + autoFulfillmentItems: [ + { + sortOrder: 0, + contentSnapshot: 'license-key-123', + sourceDigitalStockItemId: 'stock-item-1', + attachments: [ + { + storageKey: 'stock/file.pdf', + sourceDigitalStockAttachmentId: 'attachment-1', + originalFilename: 'file.pdf', + mimeType: 'application/pdf', + sizeBytes: 1024 + } + ] + } + ] + }) + ] + }) + ); + }); +}); diff --git a/backend/src/modules/order/services/OrderCreationService.ts b/backend/src/modules/order/services/OrderCreationService.ts new file mode 100644 index 0000000..04b05f4 --- /dev/null +++ b/backend/src/modules/order/services/OrderCreationService.ts @@ -0,0 +1,161 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, type EntityManager } from 'typeorm'; +import { deriveInvoiceState } from '../../../utils/invoice/deriveInvoiceState'; +import { NotificationService } from '../../notifications/services/NotificationService'; +import { DeliveryMode } from '../../product/types/DeliveryMode'; +import { CheckoutSession } from '../../storefrontCheckout/entities/CheckoutSession'; +import { CheckoutSessionLine } from '../../storefrontCheckout/entities/CheckoutSessionLine'; +import { Order } from '../entities/Order'; +import { ManualLineFulfillmentStatus } from '../types/ManualLineFulfillmentStatus'; +import { OrderFailureReason } from '../types/OrderFailureReason'; +import type { PreparedStockClaim } from '../types/PreparedStockClaim'; +import { isPreparedDigitalStockClaim, isPreparedManualStockClaim } from '../utils/isPreparedStockClaim'; +import { OrderAccessTokenService } from './OrderAccessTokenService'; +import { OrderClaimService } from './OrderClaimService'; + +@Injectable() +export class OrderCreationService { + constructor( + private readonly dataSource: DataSource, + private readonly orderClaimService: OrderClaimService, + private readonly accessTokenService: OrderAccessTokenService, + private readonly notificationService: NotificationService + ) {} + + async createFromPaidSession(sessionId: string): Promise { + const now = new Date(); + let createdOrderId: string | null = null; + + await this.dataSource.transaction(async manager => { + const sessionRepo = manager.getRepository(CheckoutSession); + const orderRepo = manager.getRepository(Order); + + const session = await sessionRepo + .createQueryBuilder('session') + .leftJoinAndSelect('session.lines', 'line') + .leftJoinAndSelect('session.discounts', 'discount') + .leftJoinAndSelect('session.invoice', 'invoice') + .leftJoinAndSelect('invoice.moneroDetails', 'moneroDetails') + .leftJoinAndSelect('invoice.payments', 'payment') + .leftJoin('session.order', 'order') + .where('session.id = :sessionId', { sessionId }) + .andWhere('session.cancelledAt IS NULL') + .andWhere('order.id IS NULL') + .andWhere('invoice.expiresAt > :now', { now }) + .setLock('pessimistic_write', undefined, ['session']) + .getOne(); + + if (!session || !session.invoice) { + return; + } + + const { isPaidSufficient } = deriveInvoiceState(session.invoice); + + if (!isPaidSufficient) { + return; + } + + const claimResult = await this.orderClaimService.claimFromSession(manager, session); + + const failureReason = claimResult.success ? null : claimResult.failureReason; + const stockClaims = claimResult.success ? claimResult.stockClaims : []; + + const { lookup, encrypted } = this.accessTokenService.generate(); + + const order = this.buildOrderFromSession( + manager, + session, + { + accessTokenLookup: lookup, + accessToken: encrypted, + failureReason + }, + stockClaims + ); + + await orderRepo.save(order); + + createdOrderId = order.id; + }); + + if (createdOrderId) { + this.notificationService.sendNotification(createdOrderId, 'newOrder'); + } + } + + private buildOrderFromSession( + manager: EntityManager, + session: CheckoutSession, + { + accessTokenLookup, + accessToken, + failureReason + }: { + accessTokenLookup: string; + accessToken: string; + failureReason: OrderFailureReason | null; + }, + stockClaims: PreparedStockClaim[] = [] + ): Order { + const orderRepo = manager.getRepository(Order); + const stockClaimsByLineId = new Map(stockClaims.map(claim => [claim.checkoutSessionLineId, claim])); + + return orderRepo.create({ + accessTokenLookup, + accessToken, + failureReason, + checkoutSession: { id: session.id }, + checkoutInvoice: { id: session.invoice.id }, + discounts: (session.discounts ?? []).map(discount => ({ + code: discount.code, + amountFiat: discount.amountFiat + })), + lines: (session.lines ?? []).map(line => this.mapCheckoutLine(line, stockClaimsByLineId)) + }); + } + + private mapCheckoutLine(source: CheckoutSessionLine, stockClaimsByLineId: Map) { + const claim = stockClaimsByLineId.get(source.id); + + const isManualStockClaim = + source.deliveryMode === DeliveryMode.Manual && claim && isPreparedManualStockClaim(claim); + + const isDigitalStockClaim = + source.deliveryMode === DeliveryMode.Auto && claim && isPreparedDigitalStockClaim(claim); + + return { + variantId: source.variantId, + productId: source.productId, + productTitle: source.productTitle, + variantTitle: source.variantTitle, + thumbnailUrl: source.thumbnailUrl, + qty: source.qty, + unitPriceFiat: source.unitPriceFiat, + lineSubtotalFiat: source.lineSubtotalFiat, + deliveryMode: source.deliveryMode, + ...(isManualStockClaim + ? { + manualFulfillment: { + status: ManualLineFulfillmentStatus.Pending + } + } + : {}), + ...(isDigitalStockClaim + ? { + autoFulfillmentItems: claim.items.map((item, index) => ({ + sortOrder: index, + contentSnapshot: item.content, + sourceDigitalStockItemId: item.id, + attachments: (item.attachments ?? []).map(attachment => ({ + storageKey: attachment.storageKey, + sourceDigitalStockAttachmentId: attachment.id, + originalFilename: attachment.originalFilename, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes + })) + })) + } + : {}) + }; + } +} diff --git a/backend/src/modules/order/services/OrderService.spec.ts b/backend/src/modules/order/services/OrderService.spec.ts new file mode 100644 index 0000000..ab43bfa --- /dev/null +++ b/backend/src/modules/order/services/OrderService.spec.ts @@ -0,0 +1,391 @@ +import type { Repository } from 'typeorm'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import type { EncryptionService } from '../../encryption/services/EncryptionService'; +import type { Invoice } from '../../payment/entities/Invoice'; +import { InvoiceReason } from '../../payment/types/InvoiceReason'; +import { PaymentMethod } from '../../payment/types/PaymentMethod'; +import type { InvoiceService } from '../../payment/services/InvoiceService'; +import { DeliveryMode } from '../../product/types/DeliveryMode'; +import type { Order } from '../entities/Order'; +import type { OrderExtended } from '../types/OrderExtended'; +import type { OrderLineManualFulfillment } from '../entities/OrderLineManualFulfillment'; +import { ManualLineFulfillmentStatus } from '../types/ManualLineFulfillmentStatus'; +import { OrderService } from './OrderService'; +import type { OrderChatService } from './OrderChatService'; +import type { OrderAccessTokenService } from './OrderAccessTokenService'; + +describe('OrderService', () => { + let orderRepo: { + findAndCount: jest.Mock; + find: jest.Mock; + findOne: jest.Mock; + update: jest.Mock; + createQueryBuilder: jest.Mock; + }; + let orderChatService: { + countUnreadBuyerMessages: jest.Mock; + }; + let invoiceService: { + issueInvoice: jest.Mock; + }; + let manualFulfillmentRepo: { + update: jest.Mock; + }; + let accessTokenService: { + decryptStored: jest.Mock; + }; + let encryptionService: { + decryptPlaintextFieldInPlace: jest.Mock; + }; + let service: OrderService; + let findByIdSpy: jest.SpyInstance; + + const orderExtended = { id: 'order-1' } as OrderExtended; + + beforeEach(() => { + orderRepo = { + findAndCount: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + update: jest.fn().mockResolvedValue(undefined), + createQueryBuilder: jest.fn() + }; + + orderChatService = { + countUnreadBuyerMessages: jest.fn().mockReturnValue(0) + }; + + invoiceService = { + issueInvoice: jest.fn().mockResolvedValue({ id: 'shipping-invoice-1' } as Invoice) + }; + + manualFulfillmentRepo = { + update: jest.fn().mockResolvedValue(undefined) + }; + + accessTokenService = { + decryptStored: jest.fn().mockReturnValue('plain-token') + }; + + encryptionService = { + decryptPlaintextFieldInPlace: jest.fn() + }; + + service = new OrderService( + orderRepo as unknown as Repository, + manualFulfillmentRepo as unknown as Repository, + orderChatService as unknown as OrderChatService, + accessTokenService as unknown as OrderAccessTokenService, + encryptionService as unknown as EncryptionService, + invoiceService as unknown as InvoiceService + ); + + findByIdSpy = jest.spyOn(service, 'findById').mockResolvedValue(orderExtended); + }); + + afterEach(() => { + findByIdSpy.mockRestore(); + }); + + it('returns paginated order list items', async () => { + const listItem = { + id: 'order-1', + status: 'open', + checkoutPaymentLabel: null, + shippingPaymentLabel: null, + totalFiat: 10, + grandTotalFiat: null, + fiatCurrency: 'USD', + lineCount: 1, + unreadMessageCount: 0, + failureReason: null, + createdAt: new Date('2026-01-02T00:00:00.000Z'), + updatedAt: new Date('2026-01-02T00:00:00.000Z') + }; + + orderRepo.findAndCount.mockResolvedValue([[{ id: 'order-1' }], 2]); + orderRepo.find.mockResolvedValue([{ id: 'order-1' }]); + jest.spyOn(service as unknown as { toOrderListItem: () => typeof listItem }, 'toOrderListItem').mockReturnValue( + listItem + ); + + const result = await service.findAll({ page: 2, limit: 1 }); + + expect(orderRepo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 1, + take: 1, + order: { createdAt: 'DESC' } + }) + ); + expect(orderRepo.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: expect.anything() } + }) + ); + expect(result).toEqual({ + items: [listItem], + total: 2, + page: 2, + limit: 1 + }); + }); + + it('returns an order id for a checkout session when one exists', async () => { + orderRepo.findOne.mockResolvedValue({ id: 'order-1' }); + + await expect(service.findIdByCheckoutSessionId('session-1')).resolves.toBe('order-1'); + }); + + it('returns null when no order exists for the checkout session', async () => { + orderRepo.findOne.mockResolvedValue(null); + + await expect(service.findIdByCheckoutSessionId('session-1')).resolves.toBeNull(); + }); + + describe('findById', () => { + let orderDetailQueryBuilder: { + leftJoinAndSelect: jest.Mock; + addSelect: jest.Mock; + orderBy: jest.Mock; + addOrderBy: jest.Mock; + where: jest.Mock; + getOne: jest.Mock; + }; + + const buildStoredOrder = (): Order => + ({ + id: 'order-1', + accessToken: 'encrypted-token', + failureReason: null, + checkoutInvoice: { + id: 'invoice-1', + fiatCurrency: 'USD', + paymentMethod: PaymentMethod.Xmr, + expectedTotalAtomic: '100000000000', + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + moneroDetails: { requiredConfirmations: 1 }, + payments: [{ id: 'pay-1', amountAtomic: '100000000000', confirmations: 1, txHash: 'tx-1' }], + reason: InvoiceReason.Checkout + }, + shippingInvoice: null, + lines: [], + messages: [], + discounts: [], + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z') + }) as unknown as Order; + + beforeEach(() => { + findByIdSpy.mockRestore(); + + orderDetailQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null) + }; + + orderRepo.createQueryBuilder = jest.fn().mockReturnValue(orderDetailQueryBuilder); + }); + + it('throws when the order cannot be found', async () => { + await expect(service.findById('order-1')).rejects.toThrow(new NotFoundException('Order not found')); + }); + + it('decrypts sensitive fields and returns an extended order view', async () => { + const storedOrder = buildStoredOrder(); + orderDetailQueryBuilder.getOne.mockResolvedValue(storedOrder); + + const result = await service.findById('order-1'); + + expect(accessTokenService.decryptStored).toHaveBeenCalledWith('encrypted-token'); + expect(encryptionService.decryptPlaintextFieldInPlace).toHaveBeenCalledWith([], 'body'); + expect(result).toEqual( + expect.objectContaining({ + id: 'order-1', + fiatCurrency: 'USD', + accessToken: 'plain-token', + checkoutInvoice: expect.objectContaining({ + statusLabel: 'Payment confirmed', + expectedTotalCrypto: '0.10000000' + }) + }) + ); + }); + }); + + describe('setDeliveryCost', () => { + const buildQuotableOrder = () => + ({ + id: 'order-1', + lines: [{ deliveryMode: DeliveryMode.Manual }], + checkoutInvoice: { id: 'checkout-invoice-1', fiatCurrency: 'USD' } + }) as Order; + + it('throws when the order cannot be quoted', async () => { + orderRepo.findOne.mockResolvedValue(null); + + await expect(service.setDeliveryCost('order-1', { deliveryCost: 5 })).rejects.toThrow( + new NotFoundException('Order not found') + ); + }); + + it('throws when the quotable order is missing a checkout invoice', async () => { + orderRepo.findOne.mockResolvedValue({ + ...buildQuotableOrder(), + checkoutInvoice: undefined + }); + + await expect(service.setDeliveryCost('order-1', { deliveryCost: 5 })).rejects.toThrow( + new NotFoundException('Order not found') + ); + }); + + it('treats already-quoted or invoiced orders as not quotable', async () => { + orderRepo.findOne.mockResolvedValue(null); + + await expect(service.setDeliveryCost('order-1', { deliveryCost: 5 })).rejects.toThrow( + new NotFoundException('Order not found') + ); + + expect(orderRepo.findOne).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + quotedAt: expect.anything(), + shippingInvoice: expect.anything() + }) + }) + ); + }); + + it('throws when the order has no manual-delivery lines', async () => { + orderRepo.findOne.mockResolvedValue({ + ...buildQuotableOrder(), + lines: [{ deliveryMode: DeliveryMode.Auto }] + }); + + await expect(service.setDeliveryCost('order-1', { deliveryCost: 5 })).rejects.toThrow( + new BadRequestException('Order does not require shipping') + ); + }); + + it('marks the order quoted without creating a shipping invoice for free delivery', async () => { + orderRepo.findOne.mockResolvedValue(buildQuotableOrder()); + + const result = await service.setDeliveryCost('order-1', { deliveryCost: 0 }); + + expect(invoiceService.issueInvoice).not.toHaveBeenCalled(); + expect(orderRepo.update).toHaveBeenCalledWith('order-1', { quotedAt: expect.any(Date) }); + expect(findByIdSpy).toHaveBeenCalledWith('order-1'); + expect(result).toBe(orderExtended); + }); + + it('issues a shipping invoice and links it when delivery has a cost', async () => { + orderRepo.findOne.mockResolvedValue(buildQuotableOrder()); + + const result = await service.setDeliveryCost('order-1', { deliveryCost: 12.5 }); + + expect(invoiceService.issueInvoice).toHaveBeenCalledWith({ + paymentMethod: PaymentMethod.Xmr, + reason: InvoiceReason.Shipping, + contextId: 'order-1', + amountFiat: 12.5 + }); + expect(orderRepo.update).toHaveBeenCalledWith('order-1', { + shippingInvoice: { id: 'shipping-invoice-1' }, + quotedAt: expect.any(Date) + }); + expect(result).toBe(orderExtended); + }); + }); + + describe('fulfillManualLine', () => { + const buildManualLine = (overrides: Record = {}) => ({ + id: 'line-1', + deliveryMode: DeliveryMode.Manual, + manualFulfillment: { + id: 'fulfillment-1', + status: ManualLineFulfillmentStatus.Pending + }, + ...overrides + }); + + it('throws when the order cannot be found', async () => { + orderRepo.findOne.mockResolvedValue(null); + + await expect(service.fulfillManualLine('order-1', 'line-1')).rejects.toThrow( + new NotFoundException('Order not found') + ); + }); + + it('throws when the order line cannot be found', async () => { + orderRepo.findOne.mockResolvedValue({ + id: 'order-1', + lines: [] + }); + + await expect(service.fulfillManualLine('order-1', 'line-1')).rejects.toThrow( + new NotFoundException('Order line not found') + ); + }); + + it('throws when the line is not manually delivered', async () => { + orderRepo.findOne.mockResolvedValue({ + id: 'order-1', + lines: [buildManualLine({ deliveryMode: DeliveryMode.Auto })] + }); + + await expect(service.fulfillManualLine('order-1', 'line-1')).rejects.toThrow( + new BadRequestException('Order line is not manually delivered') + ); + }); + + it('throws when the line has no manual fulfillment record', async () => { + orderRepo.findOne.mockResolvedValue({ + id: 'order-1', + lines: [buildManualLine({ manualFulfillment: undefined })] + }); + + await expect(service.fulfillManualLine('order-1', 'line-1')).rejects.toThrow( + new BadRequestException('Order line has no manual fulfillment record') + ); + }); + + it('throws when the line is already fulfilled', async () => { + orderRepo.findOne.mockResolvedValue({ + id: 'order-1', + lines: [ + buildManualLine({ + manualFulfillment: { + id: 'fulfillment-1', + status: ManualLineFulfillmentStatus.Fulfilled + } + }) + ] + }); + + await expect(service.fulfillManualLine('order-1', 'line-1')).rejects.toThrow( + new BadRequestException('Order line is already fulfilled') + ); + }); + + it('marks a pending manual line as fulfilled', async () => { + orderRepo.findOne.mockResolvedValue({ + id: 'order-1', + lines: [buildManualLine()] + }); + + const result = await service.fulfillManualLine('order-1', 'line-1'); + + expect(manualFulfillmentRepo.update).toHaveBeenCalledWith('fulfillment-1', { + status: ManualLineFulfillmentStatus.Fulfilled, + fulfilledAt: expect.any(Date) + }); + expect(findByIdSpy).toHaveBeenCalledWith('order-1'); + expect(result).toBe(orderExtended); + }); + }); +}); diff --git a/backend/src/modules/order/services/OrderService.ts b/backend/src/modules/order/services/OrderService.ts new file mode 100644 index 0000000..fb8b2eb --- /dev/null +++ b/backend/src/modules/order/services/OrderService.ts @@ -0,0 +1,299 @@ +import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, IsNull, Repository } from 'typeorm'; +import { createOrderDetailQuery } from '../../../utils/order/createOrderDetailQuery'; +import { deriveOrderState } from '../../../utils/order/deriveOrderState'; +import { deriveOrderTotals } from '../../../utils/order/deriveOrderTotals'; +import { formatInvoicePaymentConfirmationStatus } from '../../../utils/invoice/formatInvoicePaymentConfirmationStatus'; +import { resolveInvoiceStatusMessage } from '../../../utils/invoice/resolveInvoiceStatusMessage'; +import { resolveInvoiceRequiredConfirmations } from '../../../utils/invoice/resolveInvoiceRequiredConfirmations'; +import type { InvoiceState } from '../../../utils/invoice/types/InvoiceState'; +import { convertXmrAtomicToXmr } from '../../../utils/monero/convertXmrAtomicToXmr'; +import type { Invoice } from '../../payment/entities/Invoice'; +import type { InvoicePayment } from '../../payment/entities/InvoicePayment'; +import type { InvoiceExtended } from '../../payment/types/InvoiceExtended'; +import type { InvoicePaymentExtended } from '../../payment/types/InvoicePaymentExtended'; +import { InvoiceReason } from '../../payment/types/InvoiceReason'; +import { InvoiceService } from '../../payment/services/InvoiceService'; +import { PaymentMethod } from '../../payment/types/PaymentMethod'; +import { DeliveryMode } from '../../product/types/DeliveryMode'; +import { SetDeliveryCostDto } from '../dto/SetDeliveryCostDto'; +import type { ListOrdersQueryDto } from '../dto/ListOrdersQueryDto'; +import { Order } from '../entities/Order'; +import { OrderLineManualFulfillment } from '../entities/OrderLineManualFulfillment'; +import { ManualLineFulfillmentStatus } from '../types/ManualLineFulfillmentStatus'; +import { EncryptionService } from '../../encryption/services/EncryptionService'; +import { OrderAccessTokenService } from './OrderAccessTokenService'; +import { OrderChatService } from './OrderChatService'; +import type { OrderExtended } from '../types/OrderExtended'; +import type { OrderListItem } from '../types/OrderListItem'; +import type { PaginatedResponse } from '../../../types/PaginatedResponse'; + +@Injectable() +export class OrderService { + constructor( + @InjectRepository(Order) + private readonly orderRepo: Repository, + @InjectRepository(OrderLineManualFulfillment) + private readonly manualFulfillmentRepo: Repository, + private readonly orderChatService: OrderChatService, + private readonly accessTokenService: OrderAccessTokenService, + private readonly encryptionService: EncryptionService, + private readonly invoiceService: InvoiceService + ) {} + + async findIdByCheckoutSessionId(checkoutSessionId: string): Promise { + const order = await this.orderRepo.findOne({ + where: { checkoutSession: { id: checkoutSessionId } }, + select: { id: true } + }); + + return order?.id ?? null; + } + + async findById(id: string): Promise { + const orderDetailQuery = createOrderDetailQuery(this.orderRepo, id); + + const order = await orderDetailQuery.getOne(); + + if (!order) { + throw new NotFoundException('Order not found'); + } + + order.accessToken = this.accessTokenService.decryptStored(order.accessToken); + + this.encryptionService.decryptPlaintextFieldInPlace(order.messages, 'body'); + + for (const line of order.lines ?? []) { + this.encryptionService.decryptPlaintextFieldInPlace(line.autoFulfillmentItems, 'contentSnapshot'); + } + + return this.toOrderExtended(order); + } + + /** + * Paginate in two steps: entities first, then hydrate relations. + * + * Do not join one-to-many relations in the paginated query — LIMIT/skip apply to joined + * rows, so a page of 20 items can return far fewer parents when each parent has + * multiple children (one-to-many row multiplication). + * + * @see https://github.com/typeorm/typeorm/issues/11316#issuecomment-2074916139 + */ + async findAll({ page = 1, limit = 20 }: ListOrdersQueryDto): Promise> { + const [orders, total] = await this.orderRepo.findAndCount({ + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit + }); + + if (orders.length === 0) { + return { items: [], total, page, limit }; + } + + const orderIds = orders.map(order => order.id); + + const ordersWithRelations = await this.orderRepo.find({ + where: { id: In(orderIds) }, + relations: [ + 'checkoutInvoice', + 'checkoutInvoice.payments', + 'checkoutInvoice.moneroDetails', + 'shippingInvoice', + 'shippingInvoice.payments', + 'shippingInvoice.moneroDetails', + 'lines', + 'lines.manualFulfillment', + 'discounts', + 'messages' + ], + order: { createdAt: 'DESC' } + }); + + return { + items: ordersWithRelations.map(order => this.toOrderListItem(order)), + total, + page, + limit + }; + } + + private toOrderListItem(order: Order): OrderListItem { + const fiatCurrency = order.checkoutInvoice?.fiatCurrency; + + if (!fiatCurrency) { + throw new InternalServerErrorException('Order is missing checkout invoice fiat currency'); + } + + const { checkoutInvoiceState, shippingInvoiceState, status } = deriveOrderState(order); + + const { totalFiat, grandTotalFiat } = deriveOrderTotals(order); + + const checkoutPaymentLabel = checkoutInvoiceState ? resolveInvoiceStatusMessage(checkoutInvoiceState) : null; + const shippingPaymentLabel = shippingInvoiceState ? resolveInvoiceStatusMessage(shippingInvoiceState) : null; + + return { + id: order.id, + status, + checkoutPaymentLabel, + shippingPaymentLabel, + totalFiat, + grandTotalFiat, + fiatCurrency, + lineCount: order.lines?.length ?? 0, + unreadMessageCount: this.orderChatService.countUnreadBuyerMessages(order), + failureReason: order.failureReason, + createdAt: order.createdAt, + updatedAt: order.updatedAt + }; + } + + private toOrderExtended(order: Order): OrderExtended { + const fiatCurrency = order.checkoutInvoice?.fiatCurrency; + + if (!fiatCurrency) { + throw new InternalServerErrorException('Order is missing checkout invoice fiat currency'); + } + + const state = deriveOrderState(order); + const totals = deriveOrderTotals(order); + + const checkoutInvoice = order.checkoutInvoice + ? this.toInvoiceExtended(order.checkoutInvoice, state.checkoutInvoiceState) + : null; + + const shippingInvoice = order.shippingInvoice + ? this.toInvoiceExtended(order.shippingInvoice, state.shippingInvoiceState) + : null; + + return { + ...order, + state, + totals, + fiatCurrency, + checkoutInvoice, + shippingInvoice + }; + } + + private toInvoiceExtended(invoice: Invoice, invoiceState: InvoiceState | null): InvoiceExtended { + const requiredConfirmations = resolveInvoiceRequiredConfirmations(invoice); + + const statusLabel = invoiceState ? resolveInvoiceStatusMessage(invoiceState) : null; + + const expectedTotalCrypto = convertXmrAtomicToXmr(invoice.expectedTotalAtomic); + + const payments = (invoice.payments ?? []).map(payment => + this.toInvoicePaymentExtended(payment, requiredConfirmations) + ); + + return { + ...invoice, + statusLabel, + expectedTotalCrypto, + payments + }; + } + + private toInvoicePaymentExtended(payment: InvoicePayment, requiredConfirmations: number): InvoicePaymentExtended { + const isConfirmed = payment.confirmations >= requiredConfirmations; + + const amountCrypto = convertXmrAtomicToXmr(payment.amountAtomic); + + const confirmationsLabel = formatInvoicePaymentConfirmationStatus({ + confirmations: payment.confirmations, + requiredConfirmations, + format: 'compact' + }); + + return { + ...payment, + amountCrypto, + isConfirmed, + confirmationsLabel + }; + } + + async setDeliveryCost(orderId: string, { deliveryCost }: SetDeliveryCostDto): Promise { + const order = await this.orderRepo.findOne({ + where: { + id: orderId, + failureReason: IsNull(), + quotedAt: IsNull(), + shippingInvoice: IsNull() + }, + relations: ['lines', 'checkoutInvoice'] + }); + + if (!order || !order.checkoutInvoice) { + throw new NotFoundException('Order not found'); + } + + const hasManualLines = order.lines.some(line => line.deliveryMode === DeliveryMode.Manual); + + if (!hasManualLines) { + throw new BadRequestException('Order does not require shipping'); + } + + const quotedAt = new Date(); + + if (deliveryCost <= 0) { + await this.orderRepo.update(orderId, { quotedAt }); + + return this.findById(orderId); + } + + const shippingInvoice = await this.invoiceService.issueInvoice({ + paymentMethod: PaymentMethod.Xmr, + reason: InvoiceReason.Shipping, + contextId: orderId, + amountFiat: deliveryCost + }); + + await this.orderRepo.update(orderId, { + shippingInvoice: { id: shippingInvoice.id }, + quotedAt + }); + + return this.findById(orderId); + } + + async fulfillManualLine(orderId: string, lineId: string): Promise { + const order = await this.orderRepo.findOne({ + where: { + id: orderId, + failureReason: IsNull() + }, + relations: ['lines', 'lines.manualFulfillment'] + }); + + if (!order) { + throw new NotFoundException('Order not found'); + } + + const line = order.lines?.find(item => item.id === lineId); + + if (!line) { + throw new NotFoundException('Order line not found'); + } + + if (line.deliveryMode !== DeliveryMode.Manual) { + throw new BadRequestException('Order line is not manually delivered'); + } + + if (!line.manualFulfillment) { + throw new BadRequestException('Order line has no manual fulfillment record'); + } + + if (line.manualFulfillment.status === ManualLineFulfillmentStatus.Fulfilled) { + throw new BadRequestException('Order line is already fulfilled'); + } + + await this.manualFulfillmentRepo.update(line.manualFulfillment.id, { + status: ManualLineFulfillmentStatus.Fulfilled, + fulfilledAt: new Date() + }); + + return this.findById(orderId); + } +} diff --git a/backend/src/modules/order/types/ClaimFromSessionResult.ts b/backend/src/modules/order/types/ClaimFromSessionResult.ts new file mode 100644 index 0000000..8c50bdd --- /dev/null +++ b/backend/src/modules/order/types/ClaimFromSessionResult.ts @@ -0,0 +1,14 @@ +import type { OrderFailureReason } from './OrderFailureReason'; +import type { PreparedStockClaim } from './PreparedStockClaim'; + +type ClaimFromSessionSuccess = { + success: true; + stockClaims: PreparedStockClaim[]; +}; + +type ClaimFromSessionFailure = { + success: false; + failureReason: OrderFailureReason; +}; + +export type ClaimFromSessionResult = ClaimFromSessionSuccess | ClaimFromSessionFailure; diff --git a/backend/src/modules/order/types/GeneratedAccessToken.ts b/backend/src/modules/order/types/GeneratedAccessToken.ts new file mode 100644 index 0000000..0355d54 --- /dev/null +++ b/backend/src/modules/order/types/GeneratedAccessToken.ts @@ -0,0 +1,5 @@ +export type GeneratedAccessToken = { + token: string; + lookup: string; + encrypted: string; +}; diff --git a/backend/src/modules/order/types/ManualLineFulfillmentStatus.ts b/backend/src/modules/order/types/ManualLineFulfillmentStatus.ts new file mode 100644 index 0000000..0cea167 --- /dev/null +++ b/backend/src/modules/order/types/ManualLineFulfillmentStatus.ts @@ -0,0 +1,4 @@ +export enum ManualLineFulfillmentStatus { + Pending = 'pending', + Fulfilled = 'fulfilled' +} diff --git a/backend/src/modules/order/types/OrderClaimServiceMocks.ts b/backend/src/modules/order/types/OrderClaimServiceMocks.ts new file mode 100644 index 0000000..b637c90 --- /dev/null +++ b/backend/src/modules/order/types/OrderClaimServiceMocks.ts @@ -0,0 +1,15 @@ +export type DigitalStockItemRepoMock = { + find: jest.Mock; + update: jest.Mock; + createQueryBuilder: jest.Mock; +}; + +export type VariantRepoMock = { + findOne: jest.Mock; + update: jest.Mock; +}; + +export type DiscountCodeRepoMock = { + findOne: jest.Mock; + update: jest.Mock; +}; diff --git a/backend/src/modules/order/types/OrderExtended.ts b/backend/src/modules/order/types/OrderExtended.ts new file mode 100644 index 0000000..baaa77b --- /dev/null +++ b/backend/src/modules/order/types/OrderExtended.ts @@ -0,0 +1,12 @@ +import type { InvoiceExtended } from '../../payment/types/InvoiceExtended'; +import type { Order } from '../entities/Order'; +import type { OrderState } from '../../../utils/order/types/OrderState'; +import type { OrderTotals } from '../../../utils/order/types/OrderTotals'; + +export type OrderExtended = Omit & { + state: OrderState; + totals: OrderTotals; + fiatCurrency: string; + checkoutInvoice: InvoiceExtended | null; + shippingInvoice: InvoiceExtended | null; +}; diff --git a/backend/src/modules/order/types/OrderFailureReason.ts b/backend/src/modules/order/types/OrderFailureReason.ts new file mode 100644 index 0000000..1a602f7 --- /dev/null +++ b/backend/src/modules/order/types/OrderFailureReason.ts @@ -0,0 +1,4 @@ +export enum OrderFailureReason { + StockUnavailable = 'stock_unavailable', + DiscountExhausted = 'discount_exhausted' +} diff --git a/backend/src/modules/order/types/OrderListItem.ts b/backend/src/modules/order/types/OrderListItem.ts new file mode 100644 index 0000000..8605b5a --- /dev/null +++ b/backend/src/modules/order/types/OrderListItem.ts @@ -0,0 +1,18 @@ +import type { InvoiceStatusLabel } from '../../../utils/invoice/types/InvoiceStatusLabel'; +import type { OrderFailureReason } from './OrderFailureReason'; +import type { OrderStatus } from './OrderStatus'; + +export type OrderListItem = { + id: string; + status: OrderStatus; + checkoutPaymentLabel: InvoiceStatusLabel | null; + shippingPaymentLabel: InvoiceStatusLabel | null; + totalFiat: number; + grandTotalFiat: number | null; + fiatCurrency: string; + lineCount: number; + unreadMessageCount: number; + failureReason: OrderFailureReason | null; + createdAt: Date; + updatedAt: Date; +}; diff --git a/backend/src/modules/order/types/OrderMessageSender.ts b/backend/src/modules/order/types/OrderMessageSender.ts new file mode 100644 index 0000000..e093a4b --- /dev/null +++ b/backend/src/modules/order/types/OrderMessageSender.ts @@ -0,0 +1,4 @@ +export enum OrderMessageSender { + Buyer = 'buyer', + Staff = 'staff' +} diff --git a/backend/src/modules/order/types/OrderStatus.ts b/backend/src/modules/order/types/OrderStatus.ts new file mode 100644 index 0000000..9432da9 --- /dev/null +++ b/backend/src/modules/order/types/OrderStatus.ts @@ -0,0 +1,5 @@ +export enum OrderStatus { + Unfulfilled = 'unfulfilled', + Fulfilled = 'fulfilled', + Unfulfillable = 'unfulfillable' +} diff --git a/backend/src/modules/order/types/PreparedDiscountRedeem.ts b/backend/src/modules/order/types/PreparedDiscountRedeem.ts new file mode 100644 index 0000000..6948ce0 --- /dev/null +++ b/backend/src/modules/order/types/PreparedDiscountRedeem.ts @@ -0,0 +1,4 @@ +export type PreparedDiscountRedeem = { + discountCodeId: string; + newRedemptionCount: number; +}; diff --git a/backend/src/modules/order/types/PreparedStockClaim.ts b/backend/src/modules/order/types/PreparedStockClaim.ts new file mode 100644 index 0000000..6e3dfec --- /dev/null +++ b/backend/src/modules/order/types/PreparedStockClaim.ts @@ -0,0 +1,14 @@ +import type { DigitalStockItem } from '../../product/entities/DigitalStockItem'; + +export type PreparedManualStockClaim = { + checkoutSessionLineId: string; + variantId: string; + newStockQuantity: number; +}; + +export type PreparedDigitalStockClaim = { + checkoutSessionLineId: string; + items: DigitalStockItem[]; +}; + +export type PreparedStockClaim = PreparedManualStockClaim | PreparedDigitalStockClaim; diff --git a/backend/src/modules/order/utils/isPreparedStockClaim.ts b/backend/src/modules/order/utils/isPreparedStockClaim.ts new file mode 100644 index 0000000..d036773 --- /dev/null +++ b/backend/src/modules/order/utils/isPreparedStockClaim.ts @@ -0,0 +1,11 @@ +import type { + PreparedDigitalStockClaim, + PreparedManualStockClaim, + PreparedStockClaim +} from '../types/PreparedStockClaim'; + +export const isPreparedManualStockClaim = (claim: PreparedStockClaim): claim is PreparedManualStockClaim => + 'variantId' in claim; + +export const isPreparedDigitalStockClaim = (claim: PreparedStockClaim): claim is PreparedDigitalStockClaim => + 'items' in claim; diff --git a/backend/src/modules/payment/PaymentModule.ts b/backend/src/modules/payment/PaymentModule.ts new file mode 100644 index 0000000..981c2ef --- /dev/null +++ b/backend/src/modules/payment/PaymentModule.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { MoneroWalletModule } from '../moneroWallet/MoneroWalletModule'; +import { XmrRateModule } from '../xmrRate/XmrRateModule'; +import { Invoice } from './entities/Invoice'; +import { InvoiceMoneroDetails } from './entities/InvoiceMoneroDetails'; +import { InvoicePayment } from './entities/InvoicePayment'; +import { InvoicePaymentService } from './services/InvoicePaymentService'; +import { InvoiceService } from './services/InvoiceService'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails]), + MoneroWalletModule, + XmrRateModule + ], + providers: [InvoicePaymentService, InvoiceService], + exports: [InvoiceService, TypeOrmModule.forFeature([Invoice])] +}) +export class PaymentModule {} diff --git a/backend/src/modules/payment/entities/Invoice.ts b/backend/src/modules/payment/entities/Invoice.ts new file mode 100644 index 0000000..f29eec4 --- /dev/null +++ b/backend/src/modules/payment/entities/Invoice.ts @@ -0,0 +1,48 @@ +import { Column, CreateDateColumn, Entity, OneToMany, OneToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { ColumnBigIntTransformer } from '../../../utils/ColumnBigIntTransformer'; +import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer'; +import { PaymentMethod } from '../types/PaymentMethod'; +import { InvoiceReason } from '../types/InvoiceReason'; +import { InvoiceMoneroDetails } from './InvoiceMoneroDetails'; +import { InvoicePayment } from './InvoicePayment'; + +@Entity('invoices') +export class Invoice { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'enum', enum: InvoiceReason }) + reason: InvoiceReason; + + @Column({ type: 'enum', enum: PaymentMethod }) + paymentMethod: PaymentMethod; + + @Column({ + type: 'numeric', + precision: 12, + scale: 2, + transformer: new ColumnNumericTransformer() + }) + amountFiat: number; + + @Column({ length: 3 }) + fiatCurrency: string; + + @Column({ type: 'timestamptz' }) + expiresAt: Date; + + @Column({ type: 'varchar', length: 255, unique: true }) + paymentAddress: string; + + @Column({ type: 'bigint', transformer: new ColumnBigIntTransformer() }) + expectedTotalAtomic: string; + + @OneToMany(() => InvoicePayment, payment => payment.invoice, { cascade: true }) + payments: InvoicePayment[]; + + @OneToOne(() => InvoiceMoneroDetails, moneroDetails => moneroDetails.invoice, { cascade: true }) + moneroDetails: InvoiceMoneroDetails | null; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/modules/payment/entities/InvoiceMoneroDetails.ts b/backend/src/modules/payment/entities/InvoiceMoneroDetails.ts new file mode 100644 index 0000000..22f5386 --- /dev/null +++ b/backend/src/modules/payment/entities/InvoiceMoneroDetails.ts @@ -0,0 +1,27 @@ +import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer'; +import { Invoice } from './Invoice'; + +@Entity('invoice_monero_details') +export class InvoiceMoneroDetails { + @PrimaryGeneratedColumn('uuid') + id: string; + + @OneToOne(() => Invoice, invoice => invoice.moneroDetails, { onDelete: 'CASCADE' }) + @JoinColumn() + invoice: Invoice; + + @Column({ type: 'int' }) + paymentAddressIndex: number; + + @Column({ + type: 'numeric', + precision: 12, + scale: 2, + transformer: new ColumnNumericTransformer() + }) + fiatPerXmrAtCreation: number; + + @Column({ type: 'int' }) + requiredConfirmations: number; +} diff --git a/backend/src/modules/payment/entities/InvoicePayment.ts b/backend/src/modules/payment/entities/InvoicePayment.ts new file mode 100644 index 0000000..3309421 --- /dev/null +++ b/backend/src/modules/payment/entities/InvoicePayment.ts @@ -0,0 +1,25 @@ +import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { ColumnBigIntTransformer } from '../../../utils/ColumnBigIntTransformer'; +import { Invoice } from './Invoice'; + +@Entity('invoice_payments') +export class InvoicePayment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => Invoice, invoice => invoice.payments, { onDelete: 'CASCADE' }) + @JoinColumn() + invoice: Invoice; + + @Column({ length: 64, unique: true }) + txHash: string; + + @Column({ type: 'bigint', transformer: new ColumnBigIntTransformer() }) + amountAtomic: string; + + @Column({ type: 'int', default: 0 }) + confirmations: number; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/modules/payment/services/InvoicePaymentService.spec.ts b/backend/src/modules/payment/services/InvoicePaymentService.spec.ts new file mode 100644 index 0000000..cb919c5 --- /dev/null +++ b/backend/src/modules/payment/services/InvoicePaymentService.spec.ts @@ -0,0 +1,368 @@ +import { Logger } from '@nestjs/common'; +import type { ConfigService } from '@nestjs/config'; +import type { DataSource, EntityManager, Repository } from 'typeorm'; +import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient'; +import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer'; +import { Invoice } from '../entities/Invoice'; +import { InvoicePayment } from '../entities/InvoicePayment'; +import { PaymentMethod } from '../types/PaymentMethod'; +import type { InvoicePaymentServiceTest } from '../types/InvoicePaymentServiceTest'; +import { InvoicePaymentService } from './InvoicePaymentService'; + +const minIncomingAtomic = '100000000'; + +const buildTransfer = ( + overrides: Partial = {} +): MoneroWalletRpcIncomingTransfer => ({ + txHash: 'tx-hash-1', + amountAtomic: '200000000', + confirmations: 1, + subaddrIndex: 3, + ...overrides +}); + +const buildInvoice = (overrides: Partial = {}): Invoice => + ({ + id: 'invoice-1', + paymentMethod: PaymentMethod.Xmr, + moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 }, + payments: [], + ...overrides + }) as Invoice; + +describe('InvoicePaymentService', () => { + let service: InvoicePaymentServiceTest; + let invoiceRepo: { + createQueryBuilder: jest.Mock; + }; + let pollQueryBuilder: { + innerJoinAndSelect: jest.Mock; + where: jest.Mock; + andWhere: jest.Mock; + getMany: jest.Mock; + }; + let walletRpcClient: { + getIncomingTransfers: jest.Mock; + }; + let configService: { + get: jest.Mock; + }; + let dataSource: { + transaction: jest.Mock; + }; + let transactionalInvoiceRepo: { + createQueryBuilder: jest.Mock; + }; + let transactionalInvoiceQueryBuilder: { + leftJoinAndSelect: jest.Mock; + where: jest.Mock; + setLock: jest.Mock; + getOne: jest.Mock; + }; + let paymentRepo: { + update: jest.Mock; + createQueryBuilder: jest.Mock; + }; + let insertQueryBuilder: { + insert: jest.Mock; + values: jest.Mock; + orIgnore: jest.Mock; + execute: jest.Mock; + }; + let manager: EntityManager; + let processInvoiceSpy: jest.SpyInstance; + let errorLogSpy: jest.SpiedFunction; + + beforeEach(() => { + errorLogSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + + pollQueryBuilder = { + innerJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]) + }; + + invoiceRepo = { + createQueryBuilder: jest.fn().mockReturnValue(pollQueryBuilder) + }; + + transactionalInvoiceQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + setLock: jest.fn().mockReturnThis(), + getOne: jest.fn() + }; + + transactionalInvoiceRepo = { + createQueryBuilder: jest.fn().mockReturnValue(transactionalInvoiceQueryBuilder) + }; + + insertQueryBuilder = { + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + orIgnore: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue(undefined) + }; + + paymentRepo = { + update: jest.fn().mockResolvedValue(undefined), + createQueryBuilder: jest.fn().mockReturnValue(insertQueryBuilder) + }; + + manager = { + getRepository: jest.fn((entity: { name: string }) => { + if (entity.name === Invoice.name) { + return transactionalInvoiceRepo; + } + + if (entity.name === InvoicePayment.name) { + return paymentRepo; + } + + throw new Error(`Unexpected repository: ${entity.name}`); + }) + } as unknown as EntityManager; + + dataSource = { + transaction: jest.fn(async (callback: (entityManager: EntityManager) => Promise) => + callback(manager) + ) + }; + + walletRpcClient = { + getIncomingTransfers: jest.fn().mockResolvedValue([]) + }; + + configService = { + get: jest.fn().mockReturnValue({ + minByMethod: { + [PaymentMethod.Xmr]: minIncomingAtomic + } + }) + }; + + service = new InvoicePaymentService( + invoiceRepo as unknown as Repository, + dataSource as unknown as DataSource, + walletRpcClient as unknown as MoneroWalletRpcClient, + configService as unknown as ConfigService + ) as unknown as InvoicePaymentServiceTest; + + processInvoiceSpy = jest.spyOn(service, 'processInvoice').mockResolvedValue(undefined); + }); + + afterEach(() => { + processInvoiceSpy.mockRestore(); + errorLogSpy.mockRestore(); + }); + + describe('pollInvoices', () => { + it('returns early when there are no open invoices', async () => { + pollQueryBuilder.getMany.mockResolvedValue([]); + + await service.pollInvoices(); + + expect(walletRpcClient.getIncomingTransfers).not.toHaveBeenCalled(); + expect(processInvoiceSpy).not.toHaveBeenCalled(); + }); + + it('returns early when incoming transfers cannot be fetched', async () => { + pollQueryBuilder.getMany.mockResolvedValue([buildInvoice()]); + walletRpcClient.getIncomingTransfers.mockRejectedValue(new Error('rpc down')); + + await service.pollInvoices(); + + expect(walletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]); + expect(processInvoiceSpy).not.toHaveBeenCalled(); + }); + + it('routes transfers to each invoice by subaddress index', async () => { + pollQueryBuilder.getMany.mockResolvedValue([ + buildInvoice({ + id: 'invoice-1', + moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails'] + }), + buildInvoice({ + id: 'invoice-2', + moneroDetails: { paymentAddressIndex: 7, requiredConfirmations: 1 } as Invoice['moneroDetails'] + }) + ]); + walletRpcClient.getIncomingTransfers.mockResolvedValue([ + buildTransfer({ subaddrIndex: 3, txHash: 'tx-a' }), + buildTransfer({ subaddrIndex: 7, txHash: 'tx-b' }) + ]); + + await service.pollInvoices(); + + expect(processInvoiceSpy).toHaveBeenNthCalledWith(1, 'invoice-1', [ + expect.objectContaining({ txHash: 'tx-a', subaddrIndex: 3 }) + ]); + expect(processInvoiceSpy).toHaveBeenNthCalledWith(2, 'invoice-2', [ + expect.objectContaining({ txHash: 'tx-b', subaddrIndex: 7 }) + ]); + }); + + it('continues processing other invoices when one invoice fails', async () => { + pollQueryBuilder.getMany.mockResolvedValue([ + buildInvoice({ id: 'invoice-1' }), + buildInvoice({ id: 'invoice-2' }) + ]); + walletRpcClient.getIncomingTransfers.mockResolvedValue([buildTransfer()]); + processInvoiceSpy.mockRestore(); + processInvoiceSpy = jest + .spyOn(service, 'processInvoice') + .mockRejectedValueOnce(new Error('invoice-1 failed')) + .mockResolvedValueOnce(undefined); + + await service.pollInvoices(); + + expect(processInvoiceSpy).toHaveBeenCalledTimes(2); + }); + it('deduplicates subaddress indices when fetching incoming transfers', async () => { + pollQueryBuilder.getMany.mockResolvedValue([ + buildInvoice({ + id: 'invoice-1', + moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails'] + }), + buildInvoice({ + id: 'invoice-2', + moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails'] + }) + ]); + walletRpcClient.getIncomingTransfers.mockResolvedValue([buildTransfer()]); + + await service.pollInvoices(); + + expect(walletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]); + expect(processInvoiceSpy).toHaveBeenCalledTimes(2); + }); + + }); + + describe('processInvoice', () => { + beforeEach(() => { + processInvoiceSpy.mockRestore(); + }); + + it('does nothing when the invoice is missing inside the transaction', async () => { + transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(null); + + await service.processInvoice('invoice-1', [buildTransfer()]); + + expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); + expect(paymentRepo.update).not.toHaveBeenCalled(); + }); + + it('skips transfers below the configured minimum', async () => { + transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice()); + + await service.processInvoice('invoice-1', [ + buildTransfer({ amountAtomic: '99999999', txHash: 'dust-tx' }) + ]); + + expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); + }); + it('inserts a payment when the transfer amount equals the configured minimum', async () => { + transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice()); + + await service.processInvoice('invoice-1', [ + buildTransfer({ txHash: 'min-tx', amountAtomic: minIncomingAtomic, confirmations: 1 }) + ]); + + expect(insertQueryBuilder.values).toHaveBeenCalledWith({ + invoice: { id: 'invoice-1' }, + txHash: 'min-tx', + amountAtomic: minIncomingAtomic, + confirmations: 1 + }); + }); + + it('processes a mixed batch of dust, new, and existing transfers', async () => { + transactionalInvoiceQueryBuilder.getOne.mockResolvedValue( + buildInvoice({ + payments: [ + { + id: 'payment-1', + txHash: 'known-tx', + amountAtomic: '200000000', + confirmations: 1 + } + ] as InvoicePayment[] + }) + ); + + await service.processInvoice('invoice-1', [ + buildTransfer({ txHash: 'dust-tx', amountAtomic: '99999999' }), + buildTransfer({ txHash: 'known-tx', confirmations: 4 }), + buildTransfer({ txHash: 'new-tx', amountAtomic: '300000000', confirmations: 2 }) + ]); + + expect(paymentRepo.update).toHaveBeenCalledWith('payment-1', { confirmations: 4 }); + expect(insertQueryBuilder.values).toHaveBeenCalledWith({ + invoice: { id: 'invoice-1' }, + txHash: 'new-tx', + amountAtomic: '300000000', + confirmations: 2 + }); + expect(insertQueryBuilder.execute).toHaveBeenCalledTimes(1); + }); + + + it('inserts a new payment for transfers at or above the minimum', async () => { + transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice()); + const transfer = buildTransfer({ txHash: 'new-tx', amountAtomic: '200000000', confirmations: 2 }); + + await service.processInvoice('invoice-1', [transfer]); + + expect(insertQueryBuilder.values).toHaveBeenCalledWith({ + invoice: { id: 'invoice-1' }, + txHash: 'new-tx', + amountAtomic: '200000000', + confirmations: 2 + }); + expect(insertQueryBuilder.orIgnore).toHaveBeenCalled(); + expect(insertQueryBuilder.execute).toHaveBeenCalled(); + }); + + it('updates confirmations for an existing payment when they change', async () => { + transactionalInvoiceQueryBuilder.getOne.mockResolvedValue( + buildInvoice({ + payments: [ + { + id: 'payment-1', + txHash: 'known-tx', + amountAtomic: '200000000', + confirmations: 1 + } + ] as InvoicePayment[] + }) + ); + + await service.processInvoice('invoice-1', [buildTransfer({ txHash: 'known-tx', confirmations: 5 })]); + + expect(paymentRepo.update).toHaveBeenCalledWith('payment-1', { confirmations: 5 }); + expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); + }); + + it('does not update an existing payment when confirmations are unchanged', async () => { + transactionalInvoiceQueryBuilder.getOne.mockResolvedValue( + buildInvoice({ + payments: [ + { + id: 'payment-1', + txHash: 'known-tx', + amountAtomic: '200000000', + confirmations: 3 + } + ] as InvoicePayment[] + }) + ); + + await service.processInvoice('invoice-1', [buildTransfer({ txHash: 'known-tx', confirmations: 3 })]); + + expect(paymentRepo.update).not.toHaveBeenCalled(); + expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/modules/payment/services/InvoicePaymentService.ts b/backend/src/modules/payment/services/InvoicePaymentService.ts new file mode 100644 index 0000000..23fe173 --- /dev/null +++ b/backend/src/modules/payment/services/InvoicePaymentService.ts @@ -0,0 +1,130 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Brackets, DataSource, Repository } from 'typeorm'; +import type { Config } from '../../../types/Config'; +import { groupIncomingMoneroTransfersBySubaddrIndex } from '../../../utils/monero/groupIncomingMoneroTransfersBySubaddrIndex'; +import { isAtomicGte } from '../../../utils/atomic/isAtomicGte'; +import { getErrorMessage } from '../../../utils/getErrorMessage'; +import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient'; +import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer'; +import { Invoice } from '../entities/Invoice'; +import { InvoicePayment } from '../entities/InvoicePayment'; +import { PaymentMethod } from '../types/PaymentMethod'; + +@Injectable() +export class InvoicePaymentService { + private readonly logger = new Logger(InvoicePaymentService.name); + + constructor( + @InjectRepository(Invoice) + private readonly invoiceRepo: Repository, + private readonly dataSource: DataSource, + private readonly walletRpcClient: MoneroWalletRpcClient, + private readonly configService: ConfigService + ) {} + + @Cron(CronExpression.EVERY_10_SECONDS) + private async pollInvoices(): Promise { + const now = new Date(); + + const invoices = await this.invoiceRepo + .createQueryBuilder('invoice') + .innerJoinAndSelect('invoice.moneroDetails', 'moneroDetails') + .where('invoice.paymentMethod = :paymentMethod', { paymentMethod: PaymentMethod.Xmr }) + .andWhere( + new Brackets(qb => { + qb.where('invoice.expiresAt > :now', { now }).orWhere( + `"moneroDetails"."requiredConfirmations" > 0 AND EXISTS ( + SELECT 1 FROM invoice_payments pollPayment + WHERE pollPayment."invoiceId" = invoice.id + AND pollPayment.confirmations < "moneroDetails"."requiredConfirmations" + )` + ); + }) + ) + .getMany(); + + if (invoices.length === 0) { + return; + } + + const subaddrIndices = [...new Set(invoices.map(invoice => invoice.moneroDetails!.paymentAddressIndex))]; + + let transfers: MoneroWalletRpcIncomingTransfer[]; + + try { + transfers = await this.walletRpcClient.getIncomingTransfers(subaddrIndices); + } catch (error) { + this.logger.error(`Failed to fetch incoming Monero transfers: ${getErrorMessage(error)}`); + + return; + } + + const transfersBySubaddrIndex = groupIncomingMoneroTransfersBySubaddrIndex(transfers); + + for (const invoice of invoices) { + const moneroDetails = invoice.moneroDetails!; + + const invoiceTransfers = transfersBySubaddrIndex.get(moneroDetails.paymentAddressIndex) ?? []; + + try { + await this.processInvoice(invoice.id, invoiceTransfers); + } catch (error) { + this.logger.error(`Failed to process invoice ${invoice.id}: ${getErrorMessage(error)}`); + } + } + } + + private async processInvoice(invoiceId: string, transfers: MoneroWalletRpcIncomingTransfer[]): Promise { + const { minByMethod } = this.configService.get('invoice') as Config['invoice']; + + await this.dataSource.transaction(async manager => { + const invoiceRepo = manager.getRepository(Invoice); + const paymentRepo = manager.getRepository(InvoicePayment); + + const invoice = await invoiceRepo + .createQueryBuilder('invoice') + .leftJoinAndSelect('invoice.payments', 'payment') + .where('invoice.id = :invoiceId', { invoiceId }) + .setLock('pessimistic_write', undefined, ['invoice']) + .getOne(); + + if (!invoice) { + return; + } + + const minIncomingAtomic = minByMethod[invoice.paymentMethod]; + const knownByTxHash = new Map((invoice.payments ?? []).map(payment => [payment.txHash, payment])); + + for (const transfer of transfers) { + const existing = knownByTxHash.get(transfer.txHash); + + if (existing) { + if (existing.confirmations !== transfer.confirmations) { + await paymentRepo.update(existing.id, { confirmations: transfer.confirmations }); + } + + continue; + } + + if (!isAtomicGte(transfer.amountAtomic, minIncomingAtomic)) { + continue; + } + + await paymentRepo + .createQueryBuilder() + .insert() + .values({ + invoice: { id: invoiceId }, + txHash: transfer.txHash, + amountAtomic: transfer.amountAtomic, + confirmations: transfer.confirmations + }) + .orIgnore() + .execute(); + } + }); + } +} diff --git a/backend/src/modules/payment/services/InvoiceService.spec.ts b/backend/src/modules/payment/services/InvoiceService.spec.ts new file mode 100644 index 0000000..ae829e2 --- /dev/null +++ b/backend/src/modules/payment/services/InvoiceService.spec.ts @@ -0,0 +1,217 @@ +import { Logger, ServiceUnavailableException } from '@nestjs/common'; +import type { ConfigService } from '@nestjs/config'; +import type { Repository } from 'typeorm'; +import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient'; +import type { XmrRateService } from '../../xmrRate/services/XmrRateService'; +import { Invoice } from '../entities/Invoice'; +import { InvoiceReason } from '../types/InvoiceReason'; +import { PaymentMethod } from '../types/PaymentMethod'; +import { InvoiceService } from './InvoiceService'; + +const confirmationTiers = [{ upToTotalFiat: 1000, minConfirmations: 1 }]; + +describe('InvoiceService', () => { + let service: InvoiceService; + let invoiceRepo: { + create: jest.Mock; + save: jest.Mock; + }; + let configService: { + get: jest.Mock; + }; + let walletRpcClient: { + createAddress: jest.Mock; + }; + let xmrRateService: { + getLiveFiatPerXmr: jest.Mock; + }; + let errorLogSpy: jest.SpiedFunction; + + beforeEach(() => { + errorLogSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + + invoiceRepo = { + create: jest.fn(data => ({ id: 'invoice-1', ...data })), + save: jest.fn(async (invoice: Invoice) => invoice) + }; + + configService = { + get: jest.fn((key: string) => { + if (key === 'shopSettings') { + return { shopFiatCurrency: 'USD' }; + } + + if (key === 'shopSettings.monero') { + return { confirmationTiers }; + } + + if (key === 'order') { + return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 }; + } + + return undefined; + }) + }; + + walletRpcClient = { + createAddress: jest.fn().mockResolvedValue({ + address: '4MoneroPaymentAddressExample', + address_index: 12 + }) + }; + + xmrRateService = { + getLiveFiatPerXmr: jest.fn().mockReturnValue(150) + }; + + service = new InvoiceService( + invoiceRepo as unknown as Repository, + configService as unknown as ConfigService, + walletRpcClient as unknown as MoneroWalletRpcClient, + xmrRateService as unknown as XmrRateService + ); + }); + + afterEach(() => { + errorLogSpy.mockRestore(); + }); + + const issueCheckoutInvoice = () => + service.issueInvoice({ + paymentMethod: PaymentMethod.Xmr, + reason: InvoiceReason.Checkout, + contextId: 'session-uuid', + amountFiat: 15 + }); + + it('throws when the live XMR rate is unavailable for checkout invoices', async () => { + xmrRateService.getLiveFiatPerXmr.mockReturnValue(null); + + await expect(issueCheckoutInvoice()).rejects.toThrow( + new ServiceUnavailableException("We can't show a price right now. Please try again in a few minutes.") + ); + + expect(walletRpcClient.createAddress).not.toHaveBeenCalled(); + }); + + it('throws and logs when wallet address allocation fails for checkout invoices', async () => { + walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down')); + + await expect(issueCheckoutInvoice()).rejects.toThrow( + new ServiceUnavailableException("We can't take payments right now. Please try again in a few minutes.") + ); + + expect(errorLogSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to allocate Monero payment address')); + expect(invoiceRepo.save).not.toHaveBeenCalled(); + }); + + it('creates a checkout invoice with converted totals and monero details', async () => { + const invoice = await issueCheckoutInvoice(); + + expect(walletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid'); + expect(invoiceRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + reason: InvoiceReason.Checkout, + paymentMethod: PaymentMethod.Xmr, + amountFiat: 15, + fiatCurrency: 'USD', + paymentAddress: '4MoneroPaymentAddressExample', + expectedTotalAtomic: '100000000000', + expiresAt: expect.any(Date), + moneroDetails: { + paymentAddressIndex: 12, + fiatPerXmrAtCreation: 150, + requiredConfirmations: 1 + } + }) + ); + expect(invoiceRepo.save).toHaveBeenCalled(); + expect(invoice).toEqual(expect.objectContaining({ id: 'invoice-1', amountFiat: 15 })); + }); + + it('uses a higher confirmation tier for larger checkout amounts', async () => { + configService.get.mockImplementation((key: string) => { + if (key === 'shopSettings') { + return { shopFiatCurrency: 'USD' }; + } + + if (key === 'shopSettings.monero') { + return { + confirmationTiers: [ + { upToTotalFiat: 10, minConfirmations: 1 }, + { upToTotalFiat: 100, minConfirmations: 5 }, + { minConfirmations: 10 } + ] + }; + } + + if (key === 'order') { + return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 }; + } + + return undefined; + }); + + await service.issueInvoice({ + paymentMethod: PaymentMethod.Xmr, + reason: InvoiceReason.Checkout, + contextId: 'session-large', + amountFiat: 75 + }); + + expect(invoiceRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + moneroDetails: expect.objectContaining({ + requiredConfirmations: 5 + }) + }) + ); + }); + + it('uses shipping-specific messages and address labels for shipping invoices', async () => { + xmrRateService.getLiveFiatPerXmr.mockReturnValue(null); + + await expect( + service.issueInvoice({ + paymentMethod: PaymentMethod.Xmr, + reason: InvoiceReason.Shipping, + contextId: 'order-1', + amountFiat: 5 + }) + ).rejects.toThrow( + new ServiceUnavailableException( + "We can't quote shipping in XMR right now. Please try again in a few minutes." + ) + ); + + xmrRateService.getLiveFiatPerXmr.mockReturnValue(150); + walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down')); + + await expect( + service.issueInvoice({ + paymentMethod: PaymentMethod.Xmr, + reason: InvoiceReason.Shipping, + contextId: 'order-1', + amountFiat: 5 + }) + ).rejects.toThrow( + new ServiceUnavailableException( + "We can't take shipping payments right now. Please try again in a few minutes." + ) + ); + + walletRpcClient.createAddress.mockResolvedValue({ + address: '4ShippingPaymentAddressExample', + address_index: 3 + }); + + await service.issueInvoice({ + paymentMethod: PaymentMethod.Xmr, + reason: InvoiceReason.Shipping, + contextId: 'order-1', + amountFiat: 5 + }); + + expect(walletRpcClient.createAddress).toHaveBeenCalledWith('order-shipping - order-1'); + }); +}); diff --git a/backend/src/modules/payment/services/InvoiceService.ts b/backend/src/modules/payment/services/InvoiceService.ts new file mode 100644 index 0000000..ba1300e --- /dev/null +++ b/backend/src/modules/payment/services/InvoiceService.ts @@ -0,0 +1,113 @@ +import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; +import { Repository } from 'typeorm'; +import dayjs from '../../../plugins/dayjs'; +import type { Config } from '../../../types/Config'; +import { getErrorMessage } from '../../../utils/getErrorMessage'; +import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr'; +import { convertXmrToXmrAtomic } from '../../../utils/monero/convertXmrToXmrAtomic'; +import { resolveMinConfirmations } from '../../../utils/monero/resolveMinConfirmations'; +import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient'; +import { XmrRateService } from '../../xmrRate/services/XmrRateService'; +import { Invoice } from '../entities/Invoice'; +import { InvoiceReason } from '../types/InvoiceReason'; +import type { IssueInvoiceData } from '../types/IssueInvoiceData'; +import type { InvoiceReasonData } from '../types/InvoiceReasonData'; +import { PaymentMethod } from '../types/PaymentMethod'; + +@Injectable() +export class InvoiceService { + private readonly logger = new Logger(InvoiceService.name); + + constructor( + @InjectRepository(Invoice) + private readonly invoiceRepo: Repository, + private readonly configService: ConfigService, + private readonly walletRpcClient: MoneroWalletRpcClient, + private readonly xmrRateService: XmrRateService + ) {} + + async issueInvoice(data: IssueInvoiceData): Promise { + switch (data.paymentMethod) { + case PaymentMethod.Xmr: + return this.issueXmrInvoice(data); + } + } + + private async issueXmrInvoice({ reason, contextId, amountFiat }: IssueInvoiceData): Promise { + const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings']; + const { confirmationTiers } = this.configService.get('shopSettings.monero') as Config['shopSettings']['monero']; + + const { rateUnavailableMessage, unavailableMessage, addressLabel, validityMs } = this.resolveReasonData( + reason, + contextId + ); + + const fiatPerXmr = this.xmrRateService.getLiveFiatPerXmr(); + + if (fiatPerXmr === null) { + throw new ServiceUnavailableException(rateUnavailableMessage); + } + + let paymentAddress: string; + let paymentAddressIndex: number; + + try { + const { address, address_index } = await this.walletRpcClient.createAddress(addressLabel); + + paymentAddress = address; + paymentAddressIndex = address_index; + } catch (error) { + this.logger.error(`Failed to allocate Monero payment address: ${getErrorMessage(error)}`); + + throw new ServiceUnavailableException(unavailableMessage); + } + + const requiredConfirmations = resolveMinConfirmations(amountFiat, confirmationTiers); + + const expiresAt = dayjs().add(validityMs, 'millisecond').toDate(); + + const expectedTotalXmr = convertFiatToXmr(amountFiat, fiatPerXmr); + const expectedTotalAtomic = convertXmrToXmrAtomic(expectedTotalXmr); + + const invoice = this.invoiceRepo.create({ + reason, + paymentMethod: PaymentMethod.Xmr, + amountFiat, + fiatCurrency: shopFiatCurrency, + expiresAt, + paymentAddress, + expectedTotalAtomic, + moneroDetails: { + paymentAddressIndex, + fiatPerXmrAtCreation: fiatPerXmr, + requiredConfirmations + } + }); + + return this.invoiceRepo.save(invoice); + } + + private resolveReasonData(reason: InvoiceReason, contextId: string): InvoiceReasonData { + const { checkoutValidityMs, shippingPaymentValidityMs } = this.configService.get('order') as Config['order']; + + switch (reason) { + case InvoiceReason.Checkout: + return { + addressLabel: `checkout - ${contextId}`, + validityMs: checkoutValidityMs, + unavailableMessage: "We can't take payments right now. Please try again in a few minutes.", + rateUnavailableMessage: "We can't show a price right now. Please try again in a few minutes." + }; + case InvoiceReason.Shipping: + return { + addressLabel: `order-shipping - ${contextId}`, + validityMs: shippingPaymentValidityMs, + unavailableMessage: "We can't take shipping payments right now. Please try again in a few minutes.", + rateUnavailableMessage: + "We can't quote shipping in XMR right now. Please try again in a few minutes." + }; + } + } +} diff --git a/backend/src/modules/payment/types/InvoiceExtended.ts b/backend/src/modules/payment/types/InvoiceExtended.ts new file mode 100644 index 0000000..9724f84 --- /dev/null +++ b/backend/src/modules/payment/types/InvoiceExtended.ts @@ -0,0 +1,9 @@ +import type { Invoice } from '../entities/Invoice'; +import type { InvoicePaymentExtended } from './InvoicePaymentExtended'; +import type { InvoiceStatusLabel } from '../../../utils/invoice/types/InvoiceStatusLabel'; + +export type InvoiceExtended = Omit & { + statusLabel: InvoiceStatusLabel | null; + expectedTotalCrypto: string; + payments: InvoicePaymentExtended[]; +}; diff --git a/backend/src/modules/payment/types/InvoicePaymentExtended.ts b/backend/src/modules/payment/types/InvoicePaymentExtended.ts new file mode 100644 index 0000000..a622714 --- /dev/null +++ b/backend/src/modules/payment/types/InvoicePaymentExtended.ts @@ -0,0 +1,7 @@ +import type { InvoicePayment } from '../entities/InvoicePayment'; + +export type InvoicePaymentExtended = InvoicePayment & { + amountCrypto: string; + isConfirmed: boolean; + confirmationsLabel: string; +}; diff --git a/backend/src/modules/payment/types/InvoicePaymentServiceTest.ts b/backend/src/modules/payment/types/InvoicePaymentServiceTest.ts new file mode 100644 index 0000000..be44805 --- /dev/null +++ b/backend/src/modules/payment/types/InvoicePaymentServiceTest.ts @@ -0,0 +1,6 @@ +import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer'; + +export type InvoicePaymentServiceTest = { + pollInvoices: () => Promise; + processInvoice: (invoiceId: string, transfers: MoneroWalletRpcIncomingTransfer[]) => Promise; +}; diff --git a/backend/src/modules/payment/types/InvoiceReason.ts b/backend/src/modules/payment/types/InvoiceReason.ts new file mode 100644 index 0000000..ac546da --- /dev/null +++ b/backend/src/modules/payment/types/InvoiceReason.ts @@ -0,0 +1,4 @@ +export enum InvoiceReason { + Checkout = 'checkout', + Shipping = 'shipping' +} diff --git a/backend/src/modules/payment/types/InvoiceReasonData.ts b/backend/src/modules/payment/types/InvoiceReasonData.ts new file mode 100644 index 0000000..d03d078 --- /dev/null +++ b/backend/src/modules/payment/types/InvoiceReasonData.ts @@ -0,0 +1,6 @@ +export type InvoiceReasonData = { + addressLabel: string; + validityMs: number; + unavailableMessage: string; + rateUnavailableMessage: string; +}; diff --git a/backend/src/modules/payment/types/IssueInvoiceData.ts b/backend/src/modules/payment/types/IssueInvoiceData.ts new file mode 100644 index 0000000..b4998c3 --- /dev/null +++ b/backend/src/modules/payment/types/IssueInvoiceData.ts @@ -0,0 +1,9 @@ +import type { InvoiceReason } from './InvoiceReason'; +import type { PaymentMethod } from './PaymentMethod'; + +export type IssueInvoiceData = { + paymentMethod: PaymentMethod; + reason: InvoiceReason; + contextId: string; + amountFiat: number; +}; diff --git a/backend/src/modules/payment/types/PaymentMethod.ts b/backend/src/modules/payment/types/PaymentMethod.ts new file mode 100644 index 0000000..28b6d58 --- /dev/null +++ b/backend/src/modules/payment/types/PaymentMethod.ts @@ -0,0 +1,3 @@ +export enum PaymentMethod { + Xmr = 'xmr' +} diff --git a/backend/src/modules/product/ProductsModule.ts b/backend/src/modules/product/ProductsModule.ts new file mode 100644 index 0000000..7a6e0f6 --- /dev/null +++ b/backend/src/modules/product/ProductsModule.ts @@ -0,0 +1,38 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { EncryptionModule } from '../encryption/EncryptionModule'; +import { CategoriesController } from './controllers/CategoriesController'; +import { ProductsController } from './controllers/ProductsController'; +import { ProductVariantsController } from './controllers/ProductVariantsController'; +import { Category } from './entities/Category'; +import { DigitalStockItem } from './entities/DigitalStockItem'; +import { DigitalStockItemAttachment } from './entities/DigitalStockItemAttachment'; +import { Product } from './entities/Product'; +import { ProductVariant } from './entities/ProductVariant'; +import { VariantImage } from './entities/VariantImage'; +import { CategoriesService } from './services/CategoriesService'; +import { ProductsService } from './services/ProductsService'; +import { ProductVariantsService } from './services/ProductVariantsService'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Product, + ProductVariant, + VariantImage, + DigitalStockItem, + DigitalStockItemAttachment, + Category + ]), + EncryptionModule + ], + controllers: [ProductVariantsController, ProductsController, CategoriesController], + providers: [ProductsService, ProductVariantsService, CategoriesService], + exports: [ + ProductsService, + ProductVariantsService, + CategoriesService, + TypeOrmModule.forFeature([Product, ProductVariant]) + ] +}) +export class ProductsModule {} diff --git a/backend/src/modules/product/config/digitalStockAttachmentUpload.ts b/backend/src/modules/product/config/digitalStockAttachmentUpload.ts new file mode 100644 index 0000000..c7f7701 --- /dev/null +++ b/backend/src/modules/product/config/digitalStockAttachmentUpload.ts @@ -0,0 +1,10 @@ +import { memoryStorage } from 'multer'; + +import { getDigitalStockAttachmentMulterConfig } from '../../../config'; +import { createUploadFilePipe } from '../../../utils/createUploadFilePipe'; + +export const digitalStockAttachmentUploadOptions = { storage: memoryStorage() }; + +const { allowedMimes, maxFileBytes } = getDigitalStockAttachmentMulterConfig(); + +export const digitalStockAttachmentFilePipe = createUploadFilePipe(allowedMimes, maxFileBytes, 'buffer'); diff --git a/backend/src/modules/product/config/variantImageUpload.ts b/backend/src/modules/product/config/variantImageUpload.ts new file mode 100644 index 0000000..4deaf3d --- /dev/null +++ b/backend/src/modules/product/config/variantImageUpload.ts @@ -0,0 +1,12 @@ +import { getProductThumbMulterConfig } from '../../../config'; +import { getVariantImagesDir } from '../../../config/uploadPaths'; +import { createDiskStorageUploadOptions } from '../../../utils/createDiskStorageUploadOptions'; +import { createUploadFilePipe } from '../../../utils/createUploadFilePipe'; + +const uploadDir = getVariantImagesDir(); + +export const variantImageUploadOptions = createDiskStorageUploadOptions(uploadDir); + +const { allowedMimes, maxFileBytes } = getProductThumbMulterConfig(); + +export const variantImageFilePipe = createUploadFilePipe(allowedMimes, maxFileBytes); diff --git a/backend/src/modules/product/controllers/CategoriesController.ts b/backend/src/modules/product/controllers/CategoriesController.ts new file mode 100644 index 0000000..79bf400 --- /dev/null +++ b/backend/src/modules/product/controllers/CategoriesController.ts @@ -0,0 +1,48 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + UseGuards +} from '@nestjs/common'; +import { JwtGuard } from '../../../guards/JwtGuard'; +import { CreateOrUpdateCategoryDto } from '../dto/CreateOrUpdateCategoryDto'; +import { CategoriesService } from '../services/CategoriesService'; + +@Controller('categories') +@UseGuards(JwtGuard) +export class CategoriesController { + constructor(private readonly categoriesService: CategoriesService) {} + + @Get('/') + findAll() { + return this.categoriesService.findAll(); + } + + @Get('/:id') + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.categoriesService.findOne(id); + } + + @Post('/') + create(@Body() dto: CreateOrUpdateCategoryDto) { + return this.categoriesService.create(dto); + } + + @Patch('/:id') + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: CreateOrUpdateCategoryDto) { + return this.categoriesService.update(id, dto); + } + + @Delete('/:id') + @HttpCode(HttpStatus.NO_CONTENT) + async remove(@Param('id', ParseUUIDPipe) id: string) { + await this.categoriesService.remove(id); + } +} diff --git a/backend/src/modules/product/controllers/ProductVariantsController.ts b/backend/src/modules/product/controllers/ProductVariantsController.ts new file mode 100644 index 0000000..2318750 --- /dev/null +++ b/backend/src/modules/product/controllers/ProductVariantsController.ts @@ -0,0 +1,191 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + Res, + StreamableFile, + UploadedFile, + UseGuards, + UseInterceptors +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { create as createContentDisposition } from 'content-disposition'; +import type { Response } from 'express'; +import { Readable } from 'node:stream'; + +import { JwtGuard } from '../../../guards/JwtGuard'; +import type { ValidatedUploadFile } from '../../../types/ValidatedUploadFile'; +import { + digitalStockAttachmentFilePipe, + digitalStockAttachmentUploadOptions +} from '../config/digitalStockAttachmentUpload'; +import { variantImageFilePipe, variantImageUploadOptions } from '../config/variantImageUpload'; +import { AddDigitalStockItemDto } from '../dto/AddDigitalStockItemDto'; +import { CreateOrUpdateProductVariantDto } from '../dto/CreateOrUpdateProductVariantDto'; +import { ListDigitalStockItemsQueryDto } from '../dto/ListDigitalStockItemsQueryDto'; +import { ListProductVariantsQueryDto } from '../dto/ListProductVariantsQueryDto'; +import { ReorderVariantImagesDto } from '../dto/ReorderVariantImagesDto'; +import { UpdateDigitalStockItemDto } from '../dto/UpdateDigitalStockItemDto'; +import { ProductVariantsService } from '../services/ProductVariantsService'; + +@Controller('products') +@UseGuards(JwtGuard) +export class ProductVariantsController { + constructor(private readonly productVariantsService: ProductVariantsService) {} + + @Get('variants') + findAll(@Query() query: ListProductVariantsQueryDto) { + return this.productVariantsService.findAll(query); + } + + @Post(':productId/variants') + create(@Param('productId', ParseUUIDPipe) productId: string, @Body() dto: CreateOrUpdateProductVariantDto) { + return this.productVariantsService.create(productId, dto); + } + + @Patch(':productId/variants/:variantId') + update( + @Param('productId', ParseUUIDPipe) productId: string, + @Param('variantId', ParseUUIDPipe) variantId: string, + @Body() dto: CreateOrUpdateProductVariantDto + ) { + return this.productVariantsService.update(productId, variantId, dto); + } + + @Get(':productId/variants/:variantId/digital-stock-items') + findDigitalStockItems( + @Param('productId', ParseUUIDPipe) productId: string, + @Param('variantId', ParseUUIDPipe) variantId: string, + @Query() query: ListDigitalStockItemsQueryDto + ) { + return this.productVariantsService.findDigitalStockItems(productId, variantId, query); + } + + @Post(':productId/variants/:variantId/digital-stock-items') + addDigitalStockItem( + @Param('productId', ParseUUIDPipe) productId: string, + @Param('variantId', ParseUUIDPipe) variantId: string, + @Body() dto: AddDigitalStockItemDto + ) { + return this.productVariantsService.addDigitalStockItem(productId, variantId, dto); + } + + @Patch(':productId/variants/:variantId/digital-stock-items/:itemId') + updateDigitalStockItem( + @Param('productId', ParseUUIDPipe) productId: string, + @Param('variantId', ParseUUIDPipe) variantId: string, + @Param('itemId', ParseUUIDPipe) itemId: string, + @Body() dto: UpdateDigitalStockItemDto + ) { + return this.productVariantsService.updateDigitalStockItem(productId, variantId, itemId, dto); + } + + @Delete(':productId/variants/:variantId/digital-stock-items/:itemId') + removeDigitalStockItem( + @Param('productId', ParseUUIDPipe) productId: string, + @Param('variantId', ParseUUIDPipe) variantId: string, + @Param('itemId', ParseUUIDPipe) itemId: string + ) { + return this.productVariantsService.removeDigitalStockItem(productId, variantId, itemId); + } + + @Post(':productId/variants/:variantId/digital-stock-items/:itemId/attachments') + @UseInterceptors(FileInterceptor('file', digitalStockAttachmentUploadOptions)) + uploadDigitalStockAttachment( + @Param('productId', ParseUUIDPipe) productId: string, + @Param('variantId', ParseUUIDPipe) variantId: string, + @Param('itemId', ParseUUIDPipe) itemId: string, + @UploadedFile(digitalStockAttachmentFilePipe) file: ValidatedUploadFile + ) { + return this.productVariantsService.uploadDigitalStockAttachment(productId, variantId, itemId, file); + } + + @Delete(':productId/variants/:variantId/digital-stock-items/:itemId/attachments/:attachmentId') + removeDigitalStockAttachment( + @Param('productId', ParseUUIDPipe) productId: string, + @Param('variantId', ParseUUIDPipe) variantId: string, + @Param('itemId', ParseUUIDPipe) itemId: string, + @Param('attachmentId', ParseUUIDPipe) attachmentId: string + ) { + return this.productVariantsService.removeDigitalStockAttachment(productId, variantId, itemId, attachmentId); + } + + @Get(':productId/variants/:variantId/digital-stock-items/:itemId/attachments/:attachmentId/download') + async downloadDigitalStockAttachment( + @Param('productId', ParseUUIDPipe) productId: string, + @Param('variantId', ParseUUIDPipe) variantId: string, + @Param('itemId', ParseUUIDPipe) itemId: string, + @Param('attachmentId', ParseUUIDPipe) attachmentId: string, + @Res({ passthrough: true }) res: Response + ): Promise { + const attachment = await this.productVariantsService.getDigitalStockAttachmentForDownload( + productId, + variantId, + itemId, + attachmentId + ); + + res.set({ + 'Content-Type': attachment.mimeType, + 'Content-Disposition': createContentDisposition(attachment.originalFilename, { type: 'attachment' }), + 'Content-Length': attachment.sizeBytes, + 'Cache-Control': 'private, no-store' + }); + + return new StreamableFile(Readable.from(attachment.content)); + } + + @Post(':productId/variants/:variantId/images') + @UseInterceptors(FileInterceptor('file', variantImageUploadOptions)) + uploadVariantImage( + @Param('productId', ParseUUIDPipe) productId: string, + @Param('variantId', ParseUUIDPipe) variantId: string, + @UploadedFile(variantImageFilePipe) file: Express.Multer.File + ) { + return this.productVariantsService.uploadVariantImage(productId, variantId, file.filename); + } + + @Delete(':productId/variants/:variantId/images/:imageId') + removeVariantImage( + @Param('productId', ParseUUIDPipe) productId: string, + @Param('variantId', ParseUUIDPipe) variantId: string, + @Param('imageId', ParseUUIDPipe) imageId: string + ) { + return this.productVariantsService.removeVariantImage(productId, variantId, imageId); + } + + @Patch(':productId/variants/:variantId/images/:imageId/set-thumbnail') + setVariantImageThumbnail( + @Param('productId', ParseUUIDPipe) productId: string, + @Param('variantId', ParseUUIDPipe) variantId: string, + @Param('imageId', ParseUUIDPipe) imageId: string + ) { + return this.productVariantsService.setVariantImageThumbnail(productId, variantId, imageId); + } + + @Patch(':productId/variants/:variantId/images/reorder') + reorderVariantImages( + @Param('productId', ParseUUIDPipe) productId: string, + @Param('variantId', ParseUUIDPipe) variantId: string, + @Body() dto: ReorderVariantImagesDto + ) { + return this.productVariantsService.reorderVariantImages(productId, variantId, dto); + } + + @Delete(':productId/variants/:variantId') + @HttpCode(HttpStatus.NO_CONTENT) + async remove( + @Param('productId', ParseUUIDPipe) productId: string, + @Param('variantId', ParseUUIDPipe) variantId: string + ): Promise { + await this.productVariantsService.remove(productId, variantId); + } +} diff --git a/backend/src/modules/product/controllers/ProductsController.ts b/backend/src/modules/product/controllers/ProductsController.ts new file mode 100644 index 0000000..9eb470c --- /dev/null +++ b/backend/src/modules/product/controllers/ProductsController.ts @@ -0,0 +1,52 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + UseGuards +} from '@nestjs/common'; + +import { JwtGuard } from '../../../guards/JwtGuard'; +import { CreateProductDto } from '../dto/CreateProductDto'; +import { ListProductsQueryDto } from '../dto/ListProductsQueryDto'; +import { UpdateProductDto } from '../dto/UpdateProductDto'; +import { ProductsService } from '../services/ProductsService'; + +@Controller('products') +@UseGuards(JwtGuard) +export class ProductsController { + constructor(private readonly productsService: ProductsService) {} + + @Post('/') + createDraft(@Body() { deliveryMode }: CreateProductDto) { + return this.productsService.createDraft(deliveryMode); + } + + @Get('/') + findAll(@Query() query: ListProductsQueryDto) { + return this.productsService.findAll(query); + } + + @Get('/:id') + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.productsService.findOne(id); + } + + @Patch('/:id') + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateProductDto) { + return this.productsService.update(id, dto); + } + + @Delete('/:id') + @HttpCode(HttpStatus.NO_CONTENT) + async remove(@Param('id', ParseUUIDPipe) id: string) { + await this.productsService.remove(id); + } +} diff --git a/backend/src/modules/product/dto/AddDigitalStockItemDto.ts b/backend/src/modules/product/dto/AddDigitalStockItemDto.ts new file mode 100644 index 0000000..1320a48 --- /dev/null +++ b/backend/src/modules/product/dto/AddDigitalStockItemDto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class AddDigitalStockItemDto { + @IsNotEmpty() + @IsString() + content: string; +} diff --git a/backend/src/modules/product/dto/CreateOrUpdateCategoryDto.ts b/backend/src/modules/product/dto/CreateOrUpdateCategoryDto.ts new file mode 100644 index 0000000..da197f8 --- /dev/null +++ b/backend/src/modules/product/dto/CreateOrUpdateCategoryDto.ts @@ -0,0 +1,18 @@ +import { IsInt, IsNotEmpty, IsString, MaxLength, Min } from 'class-validator'; +import { getAppConfig } from '../../../config'; + +const { + validation: { categoryNameMaxLength } +} = getAppConfig(); + +export class CreateOrUpdateCategoryDto { + @IsNotEmpty() + @IsString() + @MaxLength(categoryNameMaxLength) + name: string; + + @IsNotEmpty() + @IsInt() + @Min(0) + sortOrder: number; +} diff --git a/backend/src/modules/product/dto/CreateOrUpdateProductVariantDto.ts b/backend/src/modules/product/dto/CreateOrUpdateProductVariantDto.ts new file mode 100644 index 0000000..6037665 --- /dev/null +++ b/backend/src/modules/product/dto/CreateOrUpdateProductVariantDto.ts @@ -0,0 +1,29 @@ +import { IsInt, IsNotEmpty, IsNumber, IsString, MaxLength, Min } from 'class-validator'; + +import { getAppConfig } from '../../../config'; + +const { + validation: { productTitleMaxLength } +} = getAppConfig(); + +export class CreateOrUpdateProductVariantDto { + @IsNotEmpty() + @IsString() + @MaxLength(productTitleMaxLength) + title: string; + + @IsNotEmpty() + @IsNumber() + @Min(0) + price: number; + + @IsNotEmpty() + @IsInt() + @Min(0) + stockQuantity: number; + + @IsNotEmpty() + @IsInt() + @Min(0) + sortOrder: number; +} diff --git a/backend/src/modules/product/dto/CreateProductDto.ts b/backend/src/modules/product/dto/CreateProductDto.ts new file mode 100644 index 0000000..06547d8 --- /dev/null +++ b/backend/src/modules/product/dto/CreateProductDto.ts @@ -0,0 +1,9 @@ +import { IsEnum, IsNotEmpty } from 'class-validator'; + +import { DeliveryMode } from '../types/DeliveryMode'; + +export class CreateProductDto { + @IsNotEmpty() + @IsEnum(DeliveryMode) + deliveryMode: DeliveryMode; +} diff --git a/backend/src/modules/product/dto/ListDigitalStockItemsQueryDto.ts b/backend/src/modules/product/dto/ListDigitalStockItemsQueryDto.ts new file mode 100644 index 0000000..ed8b1fe --- /dev/null +++ b/backend/src/modules/product/dto/ListDigitalStockItemsQueryDto.ts @@ -0,0 +1,22 @@ +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class ListDigitalStockItemsQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; + + @IsOptional() + @Type(() => Number) + @IsIn([0, 1]) + hideSold?: 0 | 1; +} diff --git a/backend/src/modules/product/dto/ListProductVariantsQueryDto.ts b/backend/src/modules/product/dto/ListProductVariantsQueryDto.ts new file mode 100644 index 0000000..c5fbaca --- /dev/null +++ b/backend/src/modules/product/dto/ListProductVariantsQueryDto.ts @@ -0,0 +1,21 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class ListProductVariantsQueryDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; +} diff --git a/backend/src/modules/product/dto/ListProductsQueryDto.ts b/backend/src/modules/product/dto/ListProductsQueryDto.ts new file mode 100644 index 0000000..2531648 --- /dev/null +++ b/backend/src/modules/product/dto/ListProductsQueryDto.ts @@ -0,0 +1,21 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class ListProductsQueryDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; +} diff --git a/backend/src/modules/product/dto/ReorderVariantImagesDto.ts b/backend/src/modules/product/dto/ReorderVariantImagesDto.ts new file mode 100644 index 0000000..5834801 --- /dev/null +++ b/backend/src/modules/product/dto/ReorderVariantImagesDto.ts @@ -0,0 +1,8 @@ +import { ArrayMinSize, IsArray, IsUUID } from 'class-validator'; + +export class ReorderVariantImagesDto { + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + imageIds: string[]; +} diff --git a/backend/src/modules/product/dto/UpdateDigitalStockItemDto.ts b/backend/src/modules/product/dto/UpdateDigitalStockItemDto.ts new file mode 100644 index 0000000..51e4c41 --- /dev/null +++ b/backend/src/modules/product/dto/UpdateDigitalStockItemDto.ts @@ -0,0 +1,8 @@ +import { IsNotEmpty, IsString, MinLength } from 'class-validator'; + +export class UpdateDigitalStockItemDto { + @IsNotEmpty() + @IsString() + @MinLength(1) + content: string; +} diff --git a/backend/src/modules/product/dto/UpdateProductDto.ts b/backend/src/modules/product/dto/UpdateProductDto.ts new file mode 100644 index 0000000..e1185ea --- /dev/null +++ b/backend/src/modules/product/dto/UpdateProductDto.ts @@ -0,0 +1,25 @@ +import { IsArray, IsBoolean, IsNotEmpty, IsString, IsUUID, MaxLength } from 'class-validator'; + +import { getAppConfig } from '../../../config'; + +const { + validation: { productTitleMaxLength } +} = getAppConfig(); + +export class UpdateProductDto { + @IsNotEmpty() + @IsString() + @MaxLength(productTitleMaxLength) + title: string; + + @IsString() + descriptionHtml: string; + + @IsNotEmpty() + @IsBoolean() + isDraft: boolean; + + @IsArray() + @IsUUID('4', { each: true }) + categoryIds: string[]; +} diff --git a/backend/src/modules/product/entities/Category.ts b/backend/src/modules/product/entities/Category.ts new file mode 100644 index 0000000..bfaccc9 --- /dev/null +++ b/backend/src/modules/product/entities/Category.ts @@ -0,0 +1,19 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('categories') +export class Category { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + name: string; + + @Column({ type: 'integer', default: 0 }) + sortOrder: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/modules/product/entities/DigitalStockItem.ts b/backend/src/modules/product/entities/DigitalStockItem.ts new file mode 100644 index 0000000..107fa85 --- /dev/null +++ b/backend/src/modules/product/entities/DigitalStockItem.ts @@ -0,0 +1,35 @@ +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn +} from 'typeorm'; +import { DigitalStockItemAttachment } from './DigitalStockItemAttachment'; +import { ProductVariant } from './ProductVariant'; + +@Entity('digital_stock_items') +export class DigitalStockItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => ProductVariant, variant => variant.digitalStockItems, { onDelete: 'CASCADE' }) + variant: ProductVariant; + + @Column({ type: 'text', select: false }) + content: string; + + @Column({ type: 'boolean', default: false }) + isSold: boolean; + + @OneToMany(() => DigitalStockItemAttachment, attachment => attachment.digitalStockItem) + attachments: DigitalStockItemAttachment[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/modules/product/entities/DigitalStockItemAttachment.ts b/backend/src/modules/product/entities/DigitalStockItemAttachment.ts new file mode 100644 index 0000000..e51add7 --- /dev/null +++ b/backend/src/modules/product/entities/DigitalStockItemAttachment.ts @@ -0,0 +1,29 @@ +import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; +import { DigitalStockItem } from './DigitalStockItem'; + +@Entity('digital_stock_item_attachments') +export class DigitalStockItemAttachment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => DigitalStockItem, item => item.attachments, { onDelete: 'CASCADE' }) + digitalStockItem: DigitalStockItem; + + @Column({ select: false }) + storageKey: string; + + @Column() + originalFilename: string; + + @Column() + mimeType: string; + + @Column({ type: 'integer' }) + sizeBytes: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/modules/product/entities/Product.ts b/backend/src/modules/product/entities/Product.ts new file mode 100644 index 0000000..e18d1dd --- /dev/null +++ b/backend/src/modules/product/entities/Product.ts @@ -0,0 +1,48 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinTable, + ManyToMany, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn +} from 'typeorm'; +import { Category } from './Category'; +import { DeliveryMode } from '../types/DeliveryMode'; +import { ProductVariant } from './ProductVariant'; + +@Entity('products') +export class Product { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ default: '' }) + title: string; + + @Column({ type: 'enum', enum: DeliveryMode, default: DeliveryMode.Auto }) + deliveryMode: DeliveryMode; + + @Column({ type: 'boolean', default: true }) + isDraft: boolean; + + @Column({ type: 'text', default: '' }) + descriptionHtml: string; + + @OneToMany(() => ProductVariant, variant => variant.product) + variants: ProductVariant[]; + + @ManyToMany(() => Category) + @JoinTable({ + name: 'products_categories', + joinColumn: { name: 'productId', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'categoryId', referencedColumnName: 'id' } + }) + categories: Category[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/modules/product/entities/ProductVariant.ts b/backend/src/modules/product/entities/ProductVariant.ts new file mode 100644 index 0000000..88499e0 --- /dev/null +++ b/backend/src/modules/product/entities/ProductVariant.ts @@ -0,0 +1,52 @@ +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn +} from 'typeorm'; +import { DigitalStockItem } from './DigitalStockItem'; +import { Product } from './Product'; +import { VariantImage } from './VariantImage'; +import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer'; + +@Entity('product_variants') +export class ProductVariant { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => Product, product => product.variants, { onDelete: 'CASCADE' }) + product: Product; + + @Column() + title: string; + + @Column({ + type: 'numeric', + precision: 12, + scale: 2, + default: 0, + transformer: new ColumnNumericTransformer() + }) + price: number; + + @Column({ type: 'integer', nullable: true, default: null }) + stockQuantity: number | null; + + @Column({ type: 'integer', default: 0 }) + sortOrder: number; + + @OneToMany(() => DigitalStockItem, item => item.variant) + digitalStockItems: DigitalStockItem[]; + + @OneToMany(() => VariantImage, image => image.variant) + images: VariantImage[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/modules/product/entities/VariantImage.ts b/backend/src/modules/product/entities/VariantImage.ts new file mode 100644 index 0000000..daf19a5 --- /dev/null +++ b/backend/src/modules/product/entities/VariantImage.ts @@ -0,0 +1,26 @@ +import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; +import { ProductVariant } from './ProductVariant'; + +@Entity('variant_images') +export class VariantImage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => ProductVariant, variant => variant.images, { onDelete: 'CASCADE' }) + variant: ProductVariant; + + @Column() + url: string; + + @Column({ type: 'integer', default: 0 }) + sortOrder: number; + + @Column({ type: 'boolean', default: false }) + isThumbnail: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/modules/product/services/CategoriesService.spec.ts b/backend/src/modules/product/services/CategoriesService.spec.ts new file mode 100644 index 0000000..e0214bd --- /dev/null +++ b/backend/src/modules/product/services/CategoriesService.spec.ts @@ -0,0 +1,48 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import type { Repository } from 'typeorm'; +import { Category } from '../entities/Category'; +import { CategoriesService } from './CategoriesService'; + +describe('CategoriesService', () => { + let service: CategoriesService; + let categoryRepo: { + find: jest.Mock; + findOne: jest.Mock; + create: jest.Mock; + insert: jest.Mock; + update: jest.Mock; + delete: jest.Mock; + }; + + beforeEach(() => { + categoryRepo = { + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn(data => ({ id: 'category-1', ...data })), + insert: jest.fn().mockResolvedValue(undefined), + update: jest.fn().mockResolvedValue(undefined), + delete: jest.fn().mockResolvedValue(undefined) + }; + + service = new CategoriesService(categoryRepo as unknown as Repository); + }); + + it('returns an empty list for findByIds when no ids are provided', async () => { + await expect(service.findByIds([])).resolves.toEqual([]); + expect(categoryRepo.find).not.toHaveBeenCalled(); + }); + + it('throws when one or more categories are missing', async () => { + categoryRepo.find.mockResolvedValue([{ id: 'category-1' }]); + + await expect(service.findByIds(['category-1', 'category-2'])).rejects.toThrow( + new BadRequestException('One or more categories were not found') + ); + }); + + it('throws when loading a missing category', async () => { + await expect(service.findOne('missing-id')).rejects.toThrow( + new NotFoundException('We could not find that category.') + ); + }); +}); diff --git a/backend/src/modules/product/services/CategoriesService.ts b/backend/src/modules/product/services/CategoriesService.ts new file mode 100644 index 0000000..f75fc5a --- /dev/null +++ b/backend/src/modules/product/services/CategoriesService.ts @@ -0,0 +1,93 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Not, Repository } from 'typeorm'; +import type { CreateOrUpdateCategoryDto } from '../dto/CreateOrUpdateCategoryDto'; +import { Category } from '../entities/Category'; + +@Injectable() +export class CategoriesService { + constructor( + @InjectRepository(Category) + private readonly categoryRepo: Repository + ) {} + + async findAll(): Promise { + return this.categoryRepo.find({ + order: { sortOrder: 'ASC', name: 'ASC' } + }); + } + + async findOne(id: string): Promise { + const entity = await this.categoryRepo.findOne({ where: { id } }); + + if (!entity) { + throw new NotFoundException('We could not find that category.'); + } + + return entity; + } + + async findByIds(ids: string[]): Promise { + if (ids.length === 0) { + return []; + } + + const uniqueIds = [...new Set(ids)]; + + const categories = await this.categoryRepo.find({ + where: { id: In(uniqueIds) } + }); + + if (categories.length !== uniqueIds.length) { + throw new BadRequestException('One or more categories were not found'); + } + + return categories; + } + + async create({ name, sortOrder }: CreateOrUpdateCategoryDto): Promise { + const normalizedName = name.trim(); + + await this.validateNameAvailable(normalizedName); + + const entity = this.categoryRepo.create({ + name: normalizedName, + sortOrder + }); + + await this.categoryRepo.insert(entity); + + return this.findOne(entity.id); + } + + async update(id: string, { name, sortOrder }: CreateOrUpdateCategoryDto): Promise { + await this.findOne(id); + + const normalizedName = name.trim(); + + await this.validateNameAvailable(normalizedName, id); + + await this.categoryRepo.update(id, { + name: normalizedName, + sortOrder + }); + + return this.findOne(id); + } + + async remove(id: string): Promise { + await this.findOne(id); + + await this.categoryRepo.delete(id); + } + + private async validateNameAvailable(name: string, excludeId?: string): Promise { + const existing = await this.categoryRepo.findOne({ + where: excludeId ? { name, id: Not(excludeId) } : { name } + }); + + if (existing) { + throw new BadRequestException('Category name already exists'); + } + } +} diff --git a/backend/src/modules/product/services/ProductVariantsService.spec.ts b/backend/src/modules/product/services/ProductVariantsService.spec.ts new file mode 100644 index 0000000..85458c6 --- /dev/null +++ b/backend/src/modules/product/services/ProductVariantsService.spec.ts @@ -0,0 +1,124 @@ +import { NotFoundException } from '@nestjs/common'; +import type { ConfigService } from '@nestjs/config'; +import type { Repository } from 'typeorm'; +import type { EncryptionService } from '../../encryption/services/EncryptionService'; +import { DeliveryMode } from '../types/DeliveryMode'; +import type { DigitalStockItem } from '../entities/DigitalStockItem'; +import type { DigitalStockItemAttachment } from '../entities/DigitalStockItemAttachment'; +import type { Product } from '../entities/Product'; +import type { ProductVariant } from '../entities/ProductVariant'; +import type { VariantImage } from '../entities/VariantImage'; +import { ProductVariantsService } from './ProductVariantsService'; + +describe('ProductVariantsService.findDigitalStockItems', () => { + let variantRepo: { + findOne: jest.Mock; + }; + let digitalStockItemRepo: { + findAndCount: jest.Mock; + createQueryBuilder: jest.Mock; + }; + let loadQueryBuilder: { + leftJoinAndSelect: jest.Mock; + addSelect: jest.Mock; + where: jest.Mock; + orderBy: jest.Mock; + addOrderBy: jest.Mock; + getMany: jest.Mock; + }; + let encryptionService: { + decryptPlaintextFieldInPlace: jest.Mock; + }; + let service: ProductVariantsService; + + beforeEach(() => { + variantRepo = { + findOne: jest.fn().mockResolvedValue({ id: 'variant-1' }) + }; + + loadQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + getMany: jest.fn() + }; + + digitalStockItemRepo = { + findAndCount: jest.fn(), + createQueryBuilder: jest.fn().mockReturnValue(loadQueryBuilder) + }; + + encryptionService = { + decryptPlaintextFieldInPlace: jest.fn() + }; + + service = new ProductVariantsService( + variantRepo as unknown as Repository, + {} as unknown as Repository, + digitalStockItemRepo as unknown as Repository, + {} as unknown as Repository, + {} as unknown as Repository, + {} as unknown as ConfigService, + encryptionService as unknown as EncryptionService + ); + }); + + it('throws when auto-delivery variant is not found', async () => { + variantRepo.findOne.mockResolvedValue(null); + + await expect( + service.findDigitalStockItems('product-1', 'variant-1', { page: 1, limit: 20 }) + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('filters out sold items by default', async () => { + digitalStockItemRepo.findAndCount.mockResolvedValue([[{ id: 'item-1' }], 1]); + loadQueryBuilder.getMany.mockResolvedValue([{ id: 'item-1', content: 'secret' }]); + + const result = await service.findDigitalStockItems('product-1', 'variant-1', { page: 1, limit: 20 }); + + expect(variantRepo.findOne).toHaveBeenCalledWith({ + where: { id: 'variant-1', product: { id: 'product-1', deliveryMode: DeliveryMode.Auto } } + }); + expect(digitalStockItemRepo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: { variant: { id: 'variant-1' }, isSold: false }, + skip: 0, + take: 20 + }) + ); + expect(digitalStockItemRepo.createQueryBuilder).toHaveBeenCalledWith('item'); + expect(loadQueryBuilder.addSelect).toHaveBeenCalledWith('item.content'); + expect(loadQueryBuilder.where).toHaveBeenCalledWith('item.id IN (:...digitalStockItemIds)', { + digitalStockItemIds: ['item-1'] + }); + expect(encryptionService.decryptPlaintextFieldInPlace).toHaveBeenCalled(); + expect(result).toEqual({ + items: [{ id: 'item-1', content: 'secret' }], + total: 1, + page: 1, + limit: 20 + }); + }); + + it('includes sold items when hideSold is 0', async () => { + digitalStockItemRepo.findAndCount.mockResolvedValue([[], 0]); + + await service.findDigitalStockItems('product-1', 'variant-1', { + page: 2, + limit: 10, + hideSold: 0 + }); + + expect(digitalStockItemRepo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: { variant: { id: 'variant-1' } }, + skip: 10, + take: 10 + }) + ); + expect(digitalStockItemRepo.createQueryBuilder).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/modules/product/services/ProductVariantsService.ts b/backend/src/modules/product/services/ProductVariantsService.ts new file mode 100644 index 0000000..5a9fc21 --- /dev/null +++ b/backend/src/modules/product/services/ProductVariantsService.ts @@ -0,0 +1,647 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { mkdirSync } from 'node:fs'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import type { Repository } from 'typeorm'; +import { ILike, In } from 'typeorm'; +import type { PaginatedResponse } from '../../../types/PaginatedResponse'; +import type { Config } from '../../../types/Config'; +import { EncryptionService } from '../../encryption/services/EncryptionService'; +import { AddDigitalStockItemDto } from '../dto/AddDigitalStockItemDto'; +import { CreateOrUpdateProductVariantDto } from '../dto/CreateOrUpdateProductVariantDto'; +import type { ListDigitalStockItemsQueryDto } from '../dto/ListDigitalStockItemsQueryDto'; +import type { ListProductVariantsQueryDto } from '../dto/ListProductVariantsQueryDto'; +import { ReorderVariantImagesDto } from '../dto/ReorderVariantImagesDto'; +import { UpdateDigitalStockItemDto } from '../dto/UpdateDigitalStockItemDto'; +import { DigitalStockItem } from '../entities/DigitalStockItem'; +import { DigitalStockItemAttachment } from '../entities/DigitalStockItemAttachment'; +import { Product } from '../entities/Product'; +import { DeliveryMode } from '../types/DeliveryMode'; +import { ProductVariant } from '../entities/ProductVariant'; +import { VariantImage } from '../entities/VariantImage'; +import type { ProductVariantExtended } from '../types/ProductVariantExtended'; +import type { ProductWithVariantsExtended } from '../types/ProductWithVariantsExtended'; +import { + getDigitalStockAttachmentsDir, + getVariantImagePublicUrl, + resolveDigitalStockAttachmentPath +} from '../../../config/uploadPaths'; +import type { ValidatedUploadFile } from '../../../types/ValidatedUploadFile'; +import { getFileExtensionFromMimeType } from '../../../utils/getFileExtensionFromMimeType'; +import { sanitizeUploadFilename } from '../../../utils/sanitizeUploadFilename'; + +@Injectable() +export class ProductVariantsService { + constructor( + @InjectRepository(ProductVariant) + private readonly variantRepo: Repository, + @InjectRepository(Product) + private readonly productRepo: Repository, + @InjectRepository(DigitalStockItem) + private readonly digitalStockItemRepo: Repository, + @InjectRepository(DigitalStockItemAttachment) + private readonly digitalStockAttachmentRepo: Repository, + @InjectRepository(VariantImage) + private readonly variantImageRepo: Repository, + private readonly configService: ConfigService, + private readonly encryptionService: EncryptionService + ) {} + + async extendProductVariants(product: Product): Promise { + const [extended] = await this.extendProductsVariants([product]); + + return extended; + } + + async extendProductsVariants(products: Product[]): Promise { + const variants = products.flatMap(product => product.variants.map(variant => ({ ...variant, product }))); + + const extendedVariants = await this.extendVariants(variants); + + const extendedById = new Map(extendedVariants.map(variant => [variant.id, variant])); + + return products.map(product => ({ + ...product, + variants: product.variants.map(variant => extendedById.get(variant.id)!) + })); + } + + async extendVariants(variants: ProductVariant[]): Promise { + if (variants.length === 0) { + return []; + } + + const digitalVariantIds = variants + .filter(variant => variant.product.deliveryMode === DeliveryMode.Auto) + .map(variant => variant.id); + + const digitalStockCounts = await this.countAvailableDigitalStockByVariantIds(digitalVariantIds); + + return variants.map(variant => ({ + ...variant, + stockAvailable: this.getVariantAvailableStock(variant.product, variant, digitalStockCounts) + })); + } + + private async countAvailableDigitalStockByVariantIds(ids: string[]): Promise> { + const counts = new Map(); + + if (ids.length === 0) { + return counts; + } + + const rows = await this.digitalStockItemRepo + .createQueryBuilder('item') + .innerJoin('item.variant', 'variant') + .select('variant.id', 'variantId') + .addSelect('COUNT(item.id)', 'count') + .where('variant.id IN (:...ids)', { ids }) + .andWhere('item.isSold = :isSold', { isSold: false }) + .groupBy('variant.id') + .getRawMany<{ variantId: string; count: string }>(); + + for (const row of rows) { + counts.set(row.variantId, parseInt(row.count)); + } + + return counts; + } + + private getVariantAvailableStock( + product: Product, + variant: ProductVariant, + digitalStockCounts: Map + ): number { + if (product.deliveryMode === DeliveryMode.Manual) { + return variant.stockQuantity ?? 0; + } + + return digitalStockCounts.get(variant.id) ?? 0; + } + + private async findDigitalStockItemById( + productId: string, + variantId: string, + itemId: string + ): Promise { + const item = await this.digitalStockItemRepo + .createQueryBuilder('item') + .leftJoinAndSelect('item.attachments', 'attachment') + .addSelect('item.content') + .innerJoin('item.variant', 'variant') + .innerJoin('variant.product', 'product') + .where('item.id = :itemId', { itemId }) + .andWhere('variant.id = :variantId', { variantId }) + .andWhere('product.id = :productId', { productId }) + .andWhere('product.deliveryMode = :deliveryMode', { deliveryMode: DeliveryMode.Auto }) + .orderBy('attachment.createdAt', 'ASC') + .getOne(); + + if (!item) { + throw new NotFoundException(); + } + + this.encryptionService.decryptPlaintextFieldInPlace([item], 'content'); + + return item; + } + + async findById(productId: string, variantId: string): Promise { + const variant = await this.variantRepo + .createQueryBuilder('variant') + .innerJoinAndSelect('variant.product', 'product') + .leftJoinAndSelect('variant.images', 'image') + .where('variant.id = :variantId', { variantId }) + .andWhere('product.id = :productId', { productId }) + .orderBy('image.sortOrder', 'ASC') + .addOrderBy('image.createdAt', 'ASC') + .getOne(); + + if (!variant) { + throw new NotFoundException(); + } + + const [extended] = await this.extendVariants([variant]); + + return extended; + } + + async create( + productId: string, + { title, price, stockQuantity, sortOrder }: CreateOrUpdateProductVariantDto + ): Promise { + const product = await this.productRepo.findOne({ where: { id: productId } }); + + if (!product) { + throw new NotFoundException(); + } + + const variant = this.variantRepo.create({ + product: { id: productId }, + title: title.trim(), + price, + stockQuantity: product.deliveryMode === DeliveryMode.Manual ? stockQuantity : null, + sortOrder + }); + + await this.variantRepo.insert(variant); + + return this.findById(productId, variant.id); + } + + async update( + productId: string, + variantId: string, + { title, price, stockQuantity, sortOrder }: CreateOrUpdateProductVariantDto + ): Promise { + const existing = await this.variantRepo.findOne({ + where: { id: variantId, product: { id: productId } }, + relations: ['product'] + }); + + if (!existing) { + throw new NotFoundException(); + } + + await this.variantRepo.update(variantId, { + title: title.trim(), + price, + stockQuantity: existing.product.deliveryMode === DeliveryMode.Manual ? stockQuantity : null, + sortOrder + }); + + return this.findById(productId, variantId); + } + + async findAll({ + search = '', + page = 1, + limit = 20 + }: ListProductVariantsQueryDto): Promise> { + const trimmedSearch = search.trim(); + + const [items, total] = await this.variantRepo.findAndCount({ + where: trimmedSearch + ? [{ title: ILike(`%${trimmedSearch}%`) }, { product: { title: ILike(`%${trimmedSearch}%`) } }] + : {}, + relations: ['product'], + order: { + product: { title: 'ASC' }, + sortOrder: 'ASC', + createdAt: 'ASC' + }, + skip: (page - 1) * limit, + take: limit + }); + + return { + items, + total, + page, + limit + }; + } + + /** + * Paginate in two steps: entities first, then hydrate relations. + * + * Do not join one-to-many relations in the paginated query — LIMIT/skip apply to joined + * rows, so a page of 20 items can return far fewer parents when each parent has + * multiple children (one-to-many row multiplication). + * + * @see https://github.com/typeorm/typeorm/issues/11316#issuecomment-2074916139 + */ + async findDigitalStockItems( + productId: string, + variantId: string, + { page = 1, limit = 20, hideSold = 1 }: ListDigitalStockItemsQueryDto + ): Promise> { + const variant = await this.variantRepo.findOne({ + where: { id: variantId, product: { id: productId, deliveryMode: DeliveryMode.Auto } } + }); + + if (!variant) { + throw new NotFoundException(); + } + + const [digitalStockItems, total] = await this.digitalStockItemRepo.findAndCount({ + where: { variant: { id: variantId }, ...(hideSold === 1 ? { isSold: false } : {}) }, + order: { createdAt: 'ASC' }, + skip: (page - 1) * limit, + take: limit + }); + + const digitalStockItemIds = digitalStockItems.map(item => item.id); + + if (digitalStockItemIds.length === 0) { + return { items: [], total, page, limit }; + } + + const digitalStockItemsWithRelations = await this.digitalStockItemRepo + .createQueryBuilder('item') + .leftJoinAndSelect('item.attachments', 'attachment') + .addSelect('item.content') + .where('item.id IN (:...digitalStockItemIds)', { digitalStockItemIds }) + .orderBy('item.createdAt', 'ASC') + .addOrderBy('attachment.createdAt', 'ASC') + .getMany(); + + this.encryptionService.decryptPlaintextFieldInPlace(digitalStockItemsWithRelations, 'content'); + + return { + items: digitalStockItemsWithRelations, + total, + page, + limit + }; + } + + async findByIds(ids: string[]): Promise { + if (ids.length === 0) { + return []; + } + + const uniqueIds = [...new Set(ids)]; + + const variants = await this.variantRepo.find({ + where: { id: In(uniqueIds) }, + relations: ['product'] + }); + + if (variants.length !== uniqueIds.length) { + throw new BadRequestException('One or more variants were not found'); + } + + return variants; + } + + async findVariantIdsByProductIds(productIds: string[]): Promise> { + if (productIds.length === 0) { + return new Map(); + } + + const variants = await this.variantRepo.find({ + where: { product: { id: In(productIds) } }, + relations: ['product'], + select: { id: true, product: { id: true } } + }); + + const variantIdsByProductId = new Map(); + + for (const variant of variants) { + const productId = variant.product.id; + const variantIds = variantIdsByProductId.get(productId) ?? []; + + variantIds.push(variant.id); + variantIdsByProductId.set(productId, variantIds); + } + + return variantIdsByProductId; + } + + async remove(productId: string, variantId: string): Promise { + const count = await this.variantRepo.count({ + where: { product: { id: productId } } + }); + + if (count <= 1) { + throw new BadRequestException('A product must have at least one variant'); + } + + await this.variantRepo.delete({ id: variantId, product: { id: productId } }); + } + + async addDigitalStockItem( + productId: string, + variantId: string, + { content }: AddDigitalStockItemDto + ): Promise { + const variant = await this.variantRepo.findOne({ + where: { id: variantId, product: { id: productId, deliveryMode: DeliveryMode.Auto } } + }); + + if (!variant) { + throw new NotFoundException(); + } + + const item = this.digitalStockItemRepo.create({ + variant: { id: variant.id }, + content: this.encryptionService.encryptPlaintext(content) + }); + + await this.digitalStockItemRepo.insert(item); + + return this.findDigitalStockItemById(productId, variantId, item.id); + } + + async updateDigitalStockItem( + productId: string, + variantId: string, + itemId: string, + { content }: UpdateDigitalStockItemDto + ): Promise { + const existing = await this.digitalStockItemRepo.findOne({ + where: { + id: itemId, + variant: { id: variantId, product: { id: productId, deliveryMode: DeliveryMode.Auto } } + } + }); + + if (!existing) { + throw new NotFoundException(); + } + + await this.digitalStockItemRepo.update(itemId, { + content: this.encryptionService.encryptPlaintext(content) + }); + + return this.findDigitalStockItemById(productId, variantId, itemId); + } + + async removeDigitalStockItem(productId: string, variantId: string, itemId: string): Promise { + const item = await this.digitalStockItemRepo.findOne({ + where: { + id: itemId, + variant: { id: variantId, product: { id: productId, deliveryMode: DeliveryMode.Auto } } + } + }); + + if (!item) { + throw new NotFoundException(); + } + + await this.digitalStockItemRepo.delete(itemId); + } + + async uploadDigitalStockAttachment( + productId: string, + variantId: string, + itemId: string, + file: ValidatedUploadFile + ): Promise { + const item = await this.digitalStockItemRepo.findOne({ + where: { + id: itemId, + variant: { id: variantId, product: { id: productId, deliveryMode: DeliveryMode.Auto } } + } + }); + + if (!item) { + throw new NotFoundException(); + } + + if (item.isSold) { + throw new BadRequestException('Cannot add attachments to a sold stock item'); + } + + const { digitalStockAttachmentsMax } = this.configService.get('app.validation') as Config['app']['validation']; + + const count = await this.digitalStockAttachmentRepo.count({ + where: { digitalStockItem: { id: itemId } } + }); + + if (count >= digitalStockAttachmentsMax) { + throw new BadRequestException(`A stock item can have at most ${digitalStockAttachmentsMax} attachments`); + } + + const ext = getFileExtensionFromMimeType(file.detectedMimeType); + const storageKey = `${randomUUID()}.${ext}`; + const absolutePath = resolveDigitalStockAttachmentPath(storageKey); + + const dir = getDigitalStockAttachmentsDir(); + + mkdirSync(dir, { recursive: true }); + + await this.encryptionService.writeEncryptedBufferToPath(file.buffer, absolutePath); + + const attachment = this.digitalStockAttachmentRepo.create({ + digitalStockItem: { id: itemId }, + storageKey, + originalFilename: sanitizeUploadFilename(file.originalname), + mimeType: file.detectedMimeType, + sizeBytes: file.size + }); + + await this.digitalStockAttachmentRepo.insert(attachment); + + return this.findDigitalStockItemById(productId, variantId, itemId); + } + + async removeDigitalStockAttachment( + productId: string, + variantId: string, + itemId: string, + attachmentId: string + ): Promise { + const attachment = await this.digitalStockAttachmentRepo.findOne({ + where: { + id: attachmentId, + digitalStockItem: { + id: itemId, + variant: { id: variantId, product: { id: productId, deliveryMode: DeliveryMode.Auto } } + } + }, + relations: ['digitalStockItem'] + }); + + if (!attachment) { + throw new NotFoundException(); + } + + if (attachment.digitalStockItem.isSold) { + throw new BadRequestException('Cannot remove attachments from a sold stock item'); + } + + await this.digitalStockAttachmentRepo.delete(attachmentId); + + return this.findDigitalStockItemById(productId, variantId, itemId); + } + + async getDigitalStockAttachmentForDownload( + productId: string, + variantId: string, + itemId: string, + attachmentId: string + ): Promise<{ content: Buffer; mimeType: string; originalFilename: string; sizeBytes: number }> { + const attachment = await this.digitalStockAttachmentRepo + .createQueryBuilder('attachment') + .innerJoin('attachment.digitalStockItem', 'stock') + .innerJoin('stock.variant', 'variant') + .innerJoin('variant.product', 'product') + .addSelect('attachment.storageKey') + .where('attachment.id = :attachmentId', { attachmentId }) + .andWhere('stock.id = :itemId', { itemId }) + .andWhere('variant.id = :variantId', { variantId }) + .andWhere('product.id = :productId', { productId }) + .andWhere('product.deliveryMode = :deliveryMode', { deliveryMode: DeliveryMode.Auto }) + .getOne(); + + if (!attachment) { + throw new NotFoundException(); + } + + const path = resolveDigitalStockAttachmentPath(attachment.storageKey); + + const content = await this.encryptionService.decryptFileAtPath(path); + + return { + content, + mimeType: attachment.mimeType, + originalFilename: attachment.originalFilename, + sizeBytes: attachment.sizeBytes + }; + } + + async uploadVariantImage(productId: string, variantId: string, filename: string): Promise { + const variant = await this.variantRepo.findOne({ + where: { id: variantId, product: { id: productId } } + }); + + if (!variant) { + throw new NotFoundException(); + } + + const { variantImagesMax } = this.configService.get('app.validation') as Config['app']['validation']; + + const count = await this.variantImageRepo.count({ + where: { variant: { id: variantId } } + }); + + if (count >= variantImagesMax) { + throw new BadRequestException(`A variant can have at most ${variantImagesMax} images`); + } + + const url = getVariantImagePublicUrl(filename); + const isThumbnail = count === 0; + + const image = this.variantImageRepo.create({ + variant: { id: variantId }, + url, + sortOrder: count, + isThumbnail + }); + + await this.variantImageRepo.insert(image); + + return this.findById(productId, variantId); + } + + async removeVariantImage(productId: string, variantId: string, imageId: string): Promise { + const image = await this.variantImageRepo.findOne({ + where: { id: imageId, variant: { id: variantId, product: { id: productId } } } + }); + + if (!image) { + throw new NotFoundException(); + } + + await this.variantImageRepo.delete(imageId); + + if (image.isThumbnail) { + const remaining = await this.variantImageRepo.find({ + where: { variant: { id: variantId } }, + order: { sortOrder: 'ASC', createdAt: 'ASC' } + }); + + if (remaining.length > 0) { + await this.variantImageRepo.update(remaining[0].id, { isThumbnail: true }); + } + } + + return this.findById(productId, variantId); + } + + async setVariantImageThumbnail( + productId: string, + variantId: string, + imageId: string + ): Promise { + const image = await this.variantImageRepo.findOne({ + where: { id: imageId, variant: { id: variantId, product: { id: productId } } } + }); + + if (!image) { + throw new NotFoundException(); + } + + await this.variantImageRepo.update({ variant: { id: variantId } }, { isThumbnail: false }); + await this.variantImageRepo.update(imageId, { isThumbnail: true }); + + return this.findById(productId, variantId); + } + + async reorderVariantImages( + productId: string, + variantId: string, + { imageIds }: ReorderVariantImagesDto + ): Promise { + const variant = await this.variantRepo.findOne({ + where: { id: variantId, product: { id: productId } } + }); + + if (!variant) { + throw new NotFoundException(); + } + + const images = await this.variantImageRepo.find({ + where: { variant: { id: variantId } }, + order: { sortOrder: 'ASC', createdAt: 'ASC' } + }); + + if (images.length !== imageIds.length) { + throw new BadRequestException('imageIds must include every image for this variant'); + } + + const imageIdsSet = new Set(imageIds); + const existingIds = new Set(images.map(image => image.id)); + + if (imageIdsSet.size !== imageIds.length || ![...imageIdsSet].every(id => existingIds.has(id))) { + throw new BadRequestException('imageIds must include every image for this variant'); + } + + await Promise.all(imageIds.map((id, index) => this.variantImageRepo.update(id, { sortOrder: index }))); + + return this.findById(productId, variantId); + } +} diff --git a/backend/src/modules/product/services/ProductsService.spec.ts b/backend/src/modules/product/services/ProductsService.spec.ts new file mode 100644 index 0000000..879721a --- /dev/null +++ b/backend/src/modules/product/services/ProductsService.spec.ts @@ -0,0 +1,39 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource, Repository } from 'typeorm'; +import type { CategoriesService } from './CategoriesService'; +import type { ProductVariantsService } from './ProductVariantsService'; +import { Product } from '../entities/Product'; +import { ProductsService } from './ProductsService'; + +describe('ProductsService', () => { + let service: ProductsService; + let productRepo: { + find: jest.Mock; + }; + + beforeEach(() => { + productRepo = { + find: jest.fn().mockResolvedValue([]) + }; + + service = new ProductsService( + productRepo as unknown as Repository, + {} as unknown as DataSource, + {} as unknown as CategoriesService, + {} as unknown as ProductVariantsService + ); + }); + + it('returns an empty list for findByIds when no ids are provided', async () => { + await expect(service.findByIds([])).resolves.toEqual([]); + expect(productRepo.find).not.toHaveBeenCalled(); + }); + + it('throws when one or more products are missing', async () => { + productRepo.find.mockResolvedValue([{ id: 'product-1' }]); + + await expect(service.findByIds(['product-1', 'product-2'])).rejects.toThrow( + new BadRequestException('One or more products were not found') + ); + }); +}); diff --git a/backend/src/modules/product/services/ProductsService.ts b/backend/src/modules/product/services/ProductsService.ts new file mode 100644 index 0000000..17b5f62 --- /dev/null +++ b/backend/src/modules/product/services/ProductsService.ts @@ -0,0 +1,196 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import type { DataSource, Repository } from 'typeorm'; +import { ILike, In } from 'typeorm'; +import type { ListProductsQueryDto } from '../dto/ListProductsQueryDto'; +import { UpdateProductDto } from '../dto/UpdateProductDto'; +import { Product } from '../entities/Product'; +import { DeliveryMode } from '../types/DeliveryMode'; +import { ProductVariant } from '../entities/ProductVariant'; +import { CategoriesService } from './CategoriesService'; +import { ProductVariantsService } from './ProductVariantsService'; +import type { PaginatedResponse } from '../../../types/PaginatedResponse'; +import type { ProductWithVariantsExtended } from '../types/ProductWithVariantsExtended'; + +@Injectable() +export class ProductsService { + constructor( + @InjectRepository(Product) + private readonly productRepo: Repository, + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly categoriesService: CategoriesService, + private readonly productVariantsService: ProductVariantsService + ) {} + + /** + * Paginate in two steps: entities first, then hydrate relations. + * + * Do not join one-to-many relations in the paginated query — LIMIT/skip apply to joined + * rows, so a page of 20 items can return far fewer parents when each parent has + * multiple children (one-to-many row multiplication). + * + * @see https://github.com/typeorm/typeorm/issues/11316#issuecomment-2074916139 + */ + async findAll({ + search = '', + page = 1, + limit = 20 + }: ListProductsQueryDto): Promise> { + const trimmedSearch = search.trim(); + + const [products, total] = await this.productRepo.findAndCount({ + where: trimmedSearch ? { title: ILike(`%${trimmedSearch}%`) } : {}, + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit + }); + + if (products.length === 0) { + return { items: [], total, page, limit }; + } + + const productIds = products.map(product => product.id); + + const productsWithRelations = await this.productRepo.find({ + where: { id: In(productIds) }, + relations: ['categories', 'variants'], + order: { + createdAt: 'DESC', + variants: { sortOrder: 'ASC', createdAt: 'ASC' } + } + }); + + return { + items: await this.productVariantsService.extendProductsVariants(productsWithRelations), + total, + page, + limit + }; + } + + async createDraft(deliveryMode: DeliveryMode): Promise { + const created = await this.dataSource.transaction(async entityManager => { + const productRepository = entityManager.getRepository(Product); + const variantRepository = entityManager.getRepository(ProductVariant); + + const product = productRepository.create({ deliveryMode }); + + await productRepository.insert(product); + + const variant = variantRepository.create({ + product: { id: product.id }, + title: 'Default Variant', + price: 0, + stockQuantity: deliveryMode === DeliveryMode.Manual ? 0 : null, + sortOrder: 0 + }); + + await variantRepository.insert(variant); + + return product.id; + }); + + return this.findOne(created); + } + + async findOne(id: string): Promise { + const entity = await this.productRepo + .createQueryBuilder('product') + .leftJoinAndSelect('product.categories', 'category') + .leftJoinAndSelect('product.variants', 'variant') + .leftJoinAndSelect('variant.images', 'image') + .where('product.id = :id', { id }) + .orderBy('variant.sortOrder', 'ASC') + .addOrderBy('variant.createdAt', 'ASC') + .addOrderBy('image.sortOrder', 'ASC') + .addOrderBy('image.createdAt', 'ASC') + .getOne(); + + if (!entity) { + throw new NotFoundException(); + } + + return this.productVariantsService.extendProductVariants(entity); + } + + async findByIds(ids: string[]): Promise { + if (ids.length === 0) { + return []; + } + + const uniqueIds = [...new Set(ids)]; + + const products = await this.productRepo.find({ + where: { id: In(uniqueIds) }, + relations: ['variants'], + order: { + createdAt: 'DESC', + variants: { sortOrder: 'ASC', createdAt: 'ASC' } + } + }); + + if (products.length !== uniqueIds.length) { + throw new BadRequestException('One or more products were not found'); + } + + return products; + } + + async findProductIdsByCategoryIds(categoryIds: string[]): Promise> { + const productIdsByCategoryId = new Map(); + + if (categoryIds.length === 0) { + return productIdsByCategoryId; + } + + const rows = await this.productRepo + .createQueryBuilder('product') + .innerJoin('product.categories', 'category') + .where('category.id IN (:...categoryIds)', { categoryIds }) + .select('product.id', 'productId') + .addSelect('category.id', 'categoryId') + .getRawMany<{ productId: string; categoryId: string }>(); + + for (const row of rows) { + const productIds = productIdsByCategoryId.get(row.categoryId) ?? []; + + productIds.push(row.productId); + productIdsByCategoryId.set(row.categoryId, productIds); + } + + return productIdsByCategoryId; + } + + async update( + id: string, + { title, descriptionHtml, isDraft, categoryIds }: UpdateProductDto + ): Promise { + const existing = await this.productRepo.findOne({ + where: { id } + }); + + if (!existing) { + throw new NotFoundException(); + } + + existing.title = title.trim(); + existing.isDraft = isDraft; + existing.descriptionHtml = descriptionHtml; + existing.categories = await this.categoriesService.findByIds(categoryIds); + + await this.productRepo.save(existing); + + return this.findOne(id); + } + + async remove(id: string): Promise { + const productEntity = await this.productRepo.findOne({ where: { id } }); + + if (!productEntity) { + throw new NotFoundException(); + } + + await this.productRepo.delete(id); + } +} diff --git a/backend/src/modules/product/types/DeliveryMode.ts b/backend/src/modules/product/types/DeliveryMode.ts new file mode 100644 index 0000000..3261d24 --- /dev/null +++ b/backend/src/modules/product/types/DeliveryMode.ts @@ -0,0 +1,4 @@ +export enum DeliveryMode { + Auto = 'auto', + Manual = 'manual' +} diff --git a/backend/src/modules/product/types/ProductVariantExtended.ts b/backend/src/modules/product/types/ProductVariantExtended.ts new file mode 100644 index 0000000..b302e51 --- /dev/null +++ b/backend/src/modules/product/types/ProductVariantExtended.ts @@ -0,0 +1,5 @@ +import type { ProductVariant } from '../entities/ProductVariant'; + +export type ProductVariantExtended = ProductVariant & { + stockAvailable: number; +}; diff --git a/backend/src/modules/product/types/ProductWithVariantsExtended.ts b/backend/src/modules/product/types/ProductWithVariantsExtended.ts new file mode 100644 index 0000000..0373bfe --- /dev/null +++ b/backend/src/modules/product/types/ProductWithVariantsExtended.ts @@ -0,0 +1,6 @@ +import type { Product } from '../entities/Product'; +import type { ProductVariantExtended } from './ProductVariantExtended'; + +export type ProductWithVariantsExtended = Omit & { + variants: ProductVariantExtended[]; +}; diff --git a/backend/src/modules/shopSettings/ShopSettingsModule.ts b/backend/src/modules/shopSettings/ShopSettingsModule.ts new file mode 100644 index 0000000..a6722a2 --- /dev/null +++ b/backend/src/modules/shopSettings/ShopSettingsModule.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SimplexModule } from '../simplex/SimplexModule'; +import { ShopSettingsController } from './controllers/ShopSettingsController'; +import { ShopSettings } from './entities/ShopSettings'; +import { ShopSettingsService } from './services/ShopSettingsService'; + +@Module({ + imports: [TypeOrmModule.forFeature([ShopSettings]), SimplexModule], + controllers: [ShopSettingsController], + providers: [ShopSettingsService], + exports: [ShopSettingsService] +}) +export class ShopSettingsModule {} diff --git a/backend/src/modules/shopSettings/config/shopFaviconUpload.ts b/backend/src/modules/shopSettings/config/shopFaviconUpload.ts new file mode 100644 index 0000000..cccc14a --- /dev/null +++ b/backend/src/modules/shopSettings/config/shopFaviconUpload.ts @@ -0,0 +1,12 @@ +import { getShopFaviconMulterConfig } from '../../../config'; +import { getShopBrandingDir } from '../../../config/uploadPaths'; +import { createDiskStorageUploadOptions } from '../../../utils/createDiskStorageUploadOptions'; +import { createUploadFilePipe } from '../../../utils/createUploadFilePipe'; + +const uploadDir = getShopBrandingDir(); + +export const shopFaviconUploadOptions = createDiskStorageUploadOptions(uploadDir); + +const { allowedMimes, maxFileBytes } = getShopFaviconMulterConfig(); + +export const shopFaviconFilePipe = createUploadFilePipe(allowedMimes, maxFileBytes); diff --git a/backend/src/modules/shopSettings/config/shopLogoUpload.ts b/backend/src/modules/shopSettings/config/shopLogoUpload.ts new file mode 100644 index 0000000..5b90d0d --- /dev/null +++ b/backend/src/modules/shopSettings/config/shopLogoUpload.ts @@ -0,0 +1,12 @@ +import { getShopLogoMulterConfig } from '../../../config'; +import { getShopBrandingDir } from '../../../config/uploadPaths'; +import { createDiskStorageUploadOptions } from '../../../utils/createDiskStorageUploadOptions'; +import { createUploadFilePipe } from '../../../utils/createUploadFilePipe'; + +const uploadDir = getShopBrandingDir(); + +export const shopLogoUploadOptions = createDiskStorageUploadOptions(uploadDir); + +const { allowedMimes, maxFileBytes } = getShopLogoMulterConfig(); + +export const shopLogoFilePipe = createUploadFilePipe(allowedMimes, maxFileBytes); diff --git a/backend/src/modules/shopSettings/controllers/ShopSettingsController.ts b/backend/src/modules/shopSettings/controllers/ShopSettingsController.ts new file mode 100644 index 0000000..6480a70 --- /dev/null +++ b/backend/src/modules/shopSettings/controllers/ShopSettingsController.ts @@ -0,0 +1,62 @@ +import { + Body, + Controller, + Get, + Patch, + Post, + UploadedFile, + UseGuards, + UseInterceptors +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { JwtGuard } from '../../../guards/JwtGuard'; +import { shopFaviconFilePipe, shopFaviconUploadOptions } from '../config/shopFaviconUpload'; +import { shopLogoFilePipe, shopLogoUploadOptions } from '../config/shopLogoUpload'; +import { ConnectSimplexNotificationsDto } from '../dto/ConnectSimplexNotificationsDto'; +import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto'; +import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto'; +import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto'; +import { ShopSettingsService } from '../services/ShopSettingsService'; + +@Controller('shop-settings') +@UseGuards(JwtGuard) +export class ShopSettingsController { + constructor(private readonly shopSettingsService: ShopSettingsService) {} + + @Get('/') + get() { + return this.shopSettingsService.getView(); + } + + @Patch('/simplex-link') + updateSimplexLink(@Body() dto: UpdateSimplexLinkDto) { + return this.shopSettingsService.updateSimplexLink(dto); + } + + @Patch('/shipping-note') + updateShippingNote(@Body() dto: UpdateShippingNoteDto) { + return this.shopSettingsService.updateShippingNote(dto); + } + + @Patch('/notifications') + updateNotifications(@Body() dto: UpdateNotificationsDto) { + return this.shopSettingsService.updateNotifications(dto); + } + + @Post('/simplex-connect') + connectSimplexNotifications(@Body() dto: ConnectSimplexNotificationsDto) { + return this.shopSettingsService.connectSimplexNotifications(dto.simplexNotificationLink); + } + + @Post('/logo') + @UseInterceptors(FileInterceptor('file', shopLogoUploadOptions)) + uploadLogo(@UploadedFile(shopLogoFilePipe) file: Express.Multer.File) { + return this.shopSettingsService.uploadLogo(file.filename); + } + + @Post('/favicon') + @UseInterceptors(FileInterceptor('file', shopFaviconUploadOptions)) + uploadFavicon(@UploadedFile(shopFaviconFilePipe) file: Express.Multer.File) { + return this.shopSettingsService.uploadFavicon(file.filename); + } +} diff --git a/backend/src/modules/shopSettings/dto/ConnectSimplexNotificationsDto.ts b/backend/src/modules/shopSettings/dto/ConnectSimplexNotificationsDto.ts new file mode 100644 index 0000000..ea535e2 --- /dev/null +++ b/backend/src/modules/shopSettings/dto/ConnectSimplexNotificationsDto.ts @@ -0,0 +1,8 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class ConnectSimplexNotificationsDto { + @IsNotEmpty() + @IsString() + @MaxLength(512) + simplexNotificationLink: string; +} diff --git a/backend/src/modules/shopSettings/dto/UpdateNotificationsDto.ts b/backend/src/modules/shopSettings/dto/UpdateNotificationsDto.ts new file mode 100644 index 0000000..5d00835 --- /dev/null +++ b/backend/src/modules/shopSettings/dto/UpdateNotificationsDto.ts @@ -0,0 +1,15 @@ +import { IsBoolean, IsNotEmpty } from 'class-validator'; + +export class UpdateNotificationsDto { + @IsNotEmpty() + @IsBoolean() + notificationsEnabled: boolean; + + @IsNotEmpty() + @IsBoolean() + notifyOnNewOrder: boolean; + + @IsNotEmpty() + @IsBoolean() + notifyOnOrderMessage: boolean; +} diff --git a/backend/src/modules/shopSettings/dto/UpdateShippingNoteDto.ts b/backend/src/modules/shopSettings/dto/UpdateShippingNoteDto.ts new file mode 100644 index 0000000..a64de70 --- /dev/null +++ b/backend/src/modules/shopSettings/dto/UpdateShippingNoteDto.ts @@ -0,0 +1,15 @@ +import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator'; + +import { getAppConfig } from '../../../config'; + +const { + validation: { shippingNoteMinLength, shippingNoteMaxLength } +} = getAppConfig(); + +export class UpdateShippingNoteDto { + @IsNotEmpty() + @IsString() + @MinLength(shippingNoteMinLength) + @MaxLength(shippingNoteMaxLength) + shippingNote: string; +} diff --git a/backend/src/modules/shopSettings/dto/UpdateSimplexLinkDto.ts b/backend/src/modules/shopSettings/dto/UpdateSimplexLinkDto.ts new file mode 100644 index 0000000..8ec7199 --- /dev/null +++ b/backend/src/modules/shopSettings/dto/UpdateSimplexLinkDto.ts @@ -0,0 +1,8 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class UpdateSimplexLinkDto { + @IsNotEmpty() + @IsString() + @MaxLength(512) + simplexLink: string; +} diff --git a/backend/src/modules/shopSettings/entities/ShopSettings.ts b/backend/src/modules/shopSettings/entities/ShopSettings.ts new file mode 100644 index 0000000..839bfa0 --- /dev/null +++ b/backend/src/modules/shopSettings/entities/ShopSettings.ts @@ -0,0 +1,40 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('shop_settings') +export class ShopSettings { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', nullable: true }) + logoStorageKey: string | null; + + @Column({ type: 'varchar', nullable: true }) + faviconStorageKey: string | null; + + @Column({ type: 'varchar', nullable: true }) + simplexLink: string | null; + + @Column({ type: 'varchar', nullable: true }) + simplexNotificationLink: string | null; + + @Column({ type: 'text', nullable: true }) + shippingNote: string | null; + + @Column({ type: 'boolean', default: false }) + notificationsEnabled: boolean; + + @Column({ type: 'boolean', default: true }) + notifyOnNewOrder: boolean; + + @Column({ type: 'boolean', default: true }) + notifyOnOrderMessage: boolean; + + @Column({ type: 'integer', nullable: true }) + simplexNotificationContactId: number | null; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/modules/shopSettings/services/ShopSettingsService.spec.ts b/backend/src/modules/shopSettings/services/ShopSettingsService.spec.ts new file mode 100644 index 0000000..2b4b964 --- /dev/null +++ b/backend/src/modules/shopSettings/services/ShopSettingsService.spec.ts @@ -0,0 +1,81 @@ +import type { ConfigService } from '@nestjs/config'; +import type { Repository } from 'typeorm'; +import type { SimplexChatClient } from '../../simplex/services/SimplexChatClient'; +import { ShopSettings } from '../entities/ShopSettings'; +import { ShopSettingsService } from './ShopSettingsService'; + +describe('ShopSettingsService', () => { + let service: ShopSettingsService; + let shopSettingsRepo: { + find: jest.Mock; + update: jest.Mock; + }; + let configService: { + get: jest.Mock; + }; + + const settings = { + id: 'settings-1', + logoStorageKey: 'logo.png', + faviconStorageKey: null, + simplexLink: 'https://simplex.example', + simplexNotificationLink: null, + shippingNote: 'Ships in 3 days', + notificationsEnabled: false, + notifyOnNewOrder: false, + notifyOnOrderMessage: false, + simplexNotificationContactId: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z') + } as ShopSettings; + + beforeEach(() => { + shopSettingsRepo = { + find: jest.fn().mockResolvedValue([settings]), + update: jest.fn().mockResolvedValue(undefined) + }; + + configService = { + get: jest.fn((key: string) => { + if (key === 'shopSettings') { + return { shopName: 'Test Shop', shopFiatCurrency: 'USD', monero: {} }; + } + + if (key === 'app') { + return { validation: { shippingNoteMinLength: 10 } }; + } + + return undefined; + }) + }; + + service = new ShopSettingsService( + shopSettingsRepo as unknown as Repository, + configService as unknown as ConfigService, + {} as unknown as SimplexChatClient + ); + }); + + it('throws when shop settings have not been initialized', async () => { + shopSettingsRepo.find.mockResolvedValue([]); + + await expect(service.findSettings()).rejects.toThrow('Shop settings have not been initialized'); + }); + + it('returns storefront branding urls and public fields', async () => { + await expect(service.getStorefrontBranding()).resolves.toEqual({ + logoUrl: '/uploads/public/shop/logo.png', + faviconUrl: null, + simplexLink: 'https://simplex.example', + shippingNote: 'Ships in 3 days' + }); + }); + + it('trims simplex links on update', async () => { + await service.updateSimplexLink({ simplexLink: ' https://simplex.example/new ' }); + + expect(shopSettingsRepo.update).toHaveBeenCalledWith('settings-1', { + simplexLink: 'https://simplex.example/new' + }); + }); +}); diff --git a/backend/src/modules/shopSettings/services/ShopSettingsService.ts b/backend/src/modules/shopSettings/services/ShopSettingsService.ts new file mode 100644 index 0000000..8df7153 --- /dev/null +++ b/backend/src/modules/shopSettings/services/ShopSettingsService.ts @@ -0,0 +1,248 @@ +import { BadGatewayException, Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { getShopBrandingPublicUrl, resolveShopBrandingPath } from '../../../config/uploadPaths'; +import { Config } from '../../../types/Config'; +import { removeFileFromDisk } from '../../../utils/removeFileFromDisk'; +import { SimplexChatClient } from '../../simplex/services/SimplexChatClient'; +import { UpdateNotificationsDto } from '../dto/UpdateNotificationsDto'; +import { UpdateShippingNoteDto } from '../dto/UpdateShippingNoteDto'; +import { UpdateSimplexLinkDto } from '../dto/UpdateSimplexLinkDto'; +import { ShopSettings } from '../entities/ShopSettings'; +import { SetupChecklist } from '../types/SetupChecklist'; +import { ShopSettingsView } from '../types/ShopSettingsView'; +import { StorefrontBranding } from '../types/StorefrontBranding'; + +@Injectable() +export class ShopSettingsService { + private readonly logger = new Logger(ShopSettingsService.name); + + private connectingSimplexPromise: Promise | null = null; + + constructor( + @InjectRepository(ShopSettings) + private readonly shopSettingsRepo: Repository, + private readonly configService: ConfigService, + private readonly simplexChatClient: SimplexChatClient + ) {} + + async getView(): Promise { + const settings = await this.findSettings(); + + return this.toView(settings); + } + + async getStorefrontBranding(): Promise { + const settings = await this.findSettings(); + + const logoUrl = settings.logoStorageKey ? getShopBrandingPublicUrl(settings.logoStorageKey) : null; + const faviconUrl = settings.faviconStorageKey ? getShopBrandingPublicUrl(settings.faviconStorageKey) : null; + + return { + logoUrl, + faviconUrl, + simplexLink: settings.simplexLink, + shippingNote: settings.shippingNote + }; + } + + async findSettings(): Promise { + const [settings] = await this.shopSettingsRepo.find({ take: 1 }); + + if (!settings) { + throw new Error('Shop settings have not been initialized'); + } + + return settings; + } + + async updateSimplexLink({ simplexLink }: UpdateSimplexLinkDto): Promise { + const settings = await this.findSettings(); + + await this.shopSettingsRepo.update(settings.id, { + simplexLink: simplexLink.trim() + }); + + return this.getView(); + } + + async updateShippingNote({ shippingNote }: UpdateShippingNoteDto): Promise { + const settings = await this.findSettings(); + + await this.shopSettingsRepo.update(settings.id, { + shippingNote: shippingNote.trim() + }); + + return this.getView(); + } + + async updateNotifications({ + notificationsEnabled, + notifyOnNewOrder, + notifyOnOrderMessage + }: UpdateNotificationsDto): Promise { + const settings = await this.findSettings(); + + await this.shopSettingsRepo.update(settings.id, { + notificationsEnabled, + notifyOnNewOrder, + notifyOnOrderMessage + }); + + return this.getView(); + } + + async uploadLogo(filename: string): Promise { + const settings = await this.findSettings(); + + const previousLogoStorageKey = settings.logoStorageKey; + + await this.shopSettingsRepo.update(settings.id, { logoStorageKey: filename }); + + if (previousLogoStorageKey) { + const previousLogoPath = resolveShopBrandingPath(previousLogoStorageKey); + + await removeFileFromDisk(previousLogoPath, ShopSettingsService.name); + } + + return this.getView(); + } + + async uploadFavicon(filename: string): Promise { + const settings = await this.findSettings(); + + const previousFaviconStorageKey = settings.faviconStorageKey; + + await this.shopSettingsRepo.update(settings.id, { faviconStorageKey: filename }); + + if (previousFaviconStorageKey) { + const previousFaviconPath = resolveShopBrandingPath(previousFaviconStorageKey); + + await removeFileFromDisk(previousFaviconPath, ShopSettingsService.name); + } + + return this.getView(); + } + + async connectSimplexNotifications(simplexNotificationLink: string): Promise { + if (this.connectingSimplexPromise) { + return this.connectingSimplexPromise; + } + + this.connectingSimplexPromise = this.runConnectSimplexNotifications(simplexNotificationLink).finally(() => { + this.connectingSimplexPromise = null; + }); + + return this.connectingSimplexPromise; + } + + private async runConnectSimplexNotifications(simplexNotificationLink: string): Promise { + const trimmedLink = simplexNotificationLink.trim(); + + try { + await this.updateSimplexNotificationLink(trimmedLink); + + const contactId = await this.simplexChatClient.connect(trimmedLink); + + await this.setSimplexNotificationContactId(contactId); + + this.logger.log(`SimpleX notification contact ready (id=${contactId})`); + + return this.getView(); + } catch { + await this.setSimplexNotificationContactId(null); + + this.logger.warn('Failed to connect SimpleX notification contact'); + + throw new BadGatewayException('Failed to connect to SimpleX'); + } + } + + async updateSimplexNotificationLink(simplexNotificationLink: string): Promise { + const settings = await this.findSettings(); + + const trimmed = simplexNotificationLink.trim(); + + const linkChanged = settings.simplexNotificationLink !== trimmed; + + await this.shopSettingsRepo.update(settings.id, { + simplexNotificationLink: trimmed, + ...(linkChanged ? { simplexNotificationContactId: null } : {}) + }); + } + + private async setSimplexNotificationContactId(contactId: number | null): Promise { + const settings = await this.findSettings(); + + await this.shopSettingsRepo.update(settings.id, { + simplexNotificationContactId: contactId + }); + } + + private toView({ + id, + logoStorageKey, + faviconStorageKey, + simplexLink, + simplexNotificationLink, + shippingNote, + notificationsEnabled, + notifyOnNewOrder, + notifyOnOrderMessage, + simplexNotificationContactId, + createdAt, + updatedAt + }: ShopSettings): ShopSettingsView { + const setupChecklist = this.buildSetupChecklist({ logoStorageKey, faviconStorageKey, simplexLink, shippingNote }); + + const { shopName, shopFiatCurrency, monero } = this.configService.get('shopSettings') as Config['shopSettings']; + + const logoUrl = logoStorageKey ? getShopBrandingPublicUrl(logoStorageKey) : null; + const faviconUrl = faviconStorageKey ? getShopBrandingPublicUrl(faviconStorageKey) : null; + + const isSetupComplete = this.isSetupComplete(setupChecklist); + + return { + id, + shopName, + shopFiatCurrency, + monero, + logoUrl, + faviconUrl, + simplexLink, + simplexNotificationLink, + shippingNote, + notificationsEnabled, + notifyOnNewOrder, + notifyOnOrderMessage, + simplexNotificationConnected: simplexNotificationContactId !== null, + isSetupComplete, + setupChecklist, + createdAt, + updatedAt + }; + } + + private buildSetupChecklist({ + logoStorageKey, + faviconStorageKey, + simplexLink, + shippingNote + }: Pick): SetupChecklist { + const { + validation: { shippingNoteMinLength } + } = this.configService.get('app') as Config['app']; + + return { + logo: logoStorageKey !== null, + favicon: faviconStorageKey !== null, + simplexLink: (simplexLink?.length ?? 0) > 0, + shippingNote: (shippingNote?.length ?? 0) >= shippingNoteMinLength + }; + } + + private isSetupComplete(checklist: SetupChecklist): boolean { + return checklist.logo && checklist.favicon && checklist.simplexLink && checklist.shippingNote; + } +} diff --git a/backend/src/modules/shopSettings/types/SetupChecklist.ts b/backend/src/modules/shopSettings/types/SetupChecklist.ts new file mode 100644 index 0000000..f58f332 --- /dev/null +++ b/backend/src/modules/shopSettings/types/SetupChecklist.ts @@ -0,0 +1,6 @@ +export interface SetupChecklist { + logo: boolean; + favicon: boolean; + simplexLink: boolean; + shippingNote: boolean; +} diff --git a/backend/src/modules/shopSettings/types/ShopSettingsMoneroView.ts b/backend/src/modules/shopSettings/types/ShopSettingsMoneroView.ts new file mode 100644 index 0000000..e6b7c6d --- /dev/null +++ b/backend/src/modules/shopSettings/types/ShopSettingsMoneroView.ts @@ -0,0 +1,5 @@ +import { MoneroConfirmationTier } from '../../../types/MoneroConfirmationTier'; + +export interface ShopSettingsMoneroView { + confirmationTiers: MoneroConfirmationTier[]; +} diff --git a/backend/src/modules/shopSettings/types/ShopSettingsView.ts b/backend/src/modules/shopSettings/types/ShopSettingsView.ts new file mode 100644 index 0000000..6e4cfef --- /dev/null +++ b/backend/src/modules/shopSettings/types/ShopSettingsView.ts @@ -0,0 +1,23 @@ +import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency'; +import { SetupChecklist } from './SetupChecklist'; +import { ShopSettingsMoneroView } from './ShopSettingsMoneroView'; + +export interface ShopSettingsView { + id: string | null; + shopName: string; + shopFiatCurrency: ShopFiatCurrency; + monero: ShopSettingsMoneroView; + logoUrl: string | null; + faviconUrl: string | null; + simplexLink: string | null; + simplexNotificationLink: string | null; + shippingNote: string | null; + notificationsEnabled: boolean; + notifyOnNewOrder: boolean; + notifyOnOrderMessage: boolean; + simplexNotificationConnected: boolean; + isSetupComplete: boolean; + setupChecklist: SetupChecklist; + createdAt: Date | null; + updatedAt: Date | null; +} diff --git a/backend/src/modules/shopSettings/types/StorefrontBranding.ts b/backend/src/modules/shopSettings/types/StorefrontBranding.ts new file mode 100644 index 0000000..39d5da5 --- /dev/null +++ b/backend/src/modules/shopSettings/types/StorefrontBranding.ts @@ -0,0 +1,6 @@ +export interface StorefrontBranding { + logoUrl: string | null; + faviconUrl: string | null; + simplexLink: string | null; + shippingNote: string | null; +} diff --git a/backend/src/modules/simplex/SimplexModule.ts b/backend/src/modules/simplex/SimplexModule.ts new file mode 100644 index 0000000..1c28bad --- /dev/null +++ b/backend/src/modules/simplex/SimplexModule.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { SimplexChatClient } from './services/SimplexChatClient'; +import { SimplexChatWsService } from './services/SimplexChatWsService'; + +@Module({ + providers: [SimplexChatWsService, SimplexChatClient], + exports: [SimplexChatClient] +}) +export class SimplexModule {} diff --git a/backend/src/modules/simplex/services/SimplexChatClient.ts b/backend/src/modules/simplex/services/SimplexChatClient.ts new file mode 100644 index 0000000..5a4a905 --- /dev/null +++ b/backend/src/modules/simplex/services/SimplexChatClient.ts @@ -0,0 +1,95 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { getErrorMessage } from '../../../utils/getErrorMessage'; +import { ConnectOutcome } from '../types/ConnectOutcome'; +import { parseSimplexConnectResponse } from '../utils/parseSimplexConnectResponse'; +import { SimplexChatWsService } from './SimplexChatWsService'; +import { SimplexChatResponseData } from '../types/SimplexChatResponseData'; + +@Injectable() +export class SimplexChatClient { + private readonly logger = new Logger(SimplexChatClient.name); + + private readonly connectTimeoutMs = 300_000; + + constructor(private readonly wsService: SimplexChatWsService) {} + + async connect(link: string): Promise { + const { promise: readyPromise, cancel: cancelReadyWait } = this.wsService.waitForEvent( + 'contactConnected', + this.connectTimeoutMs + ); + + try { + const outcome = await this.runConnect(link); + + if (outcome.kind === 'connected') { + cancelReadyWait(); + + return outcome.contactId; + } + + if (outcome.kind === 'error') { + cancelReadyWait(); + + throw new Error(outcome.message); + } + + const event = await readyPromise; + + if (!event.contact) { + throw new Error('SimpleX contact-connected event did not include a contact'); + } + + const contactId = event.contact.contactId; + + await this.sendText(contactId, 'Your shop bot is now connected and ready to send notifications!'); + + return contactId; + } catch (error) { + cancelReadyWait(); + + throw error; + } + } + + private async runConnect(link: string): Promise { + try { + const resp = await this.wsService.sendCommand(`/connect ${link}`); + const outcome = parseSimplexConnectResponse(resp); + + if (outcome.kind === 'error') { + this.logger.warn(`SimpleX /connect failed: ${JSON.stringify(resp)}`); + } + + return outcome; + } catch (error) { + return { + kind: 'error', + message: getErrorMessage(error) + }; + } + } + + async sendText(contactId: number, text: string): Promise { + const composed = [ + { + msgContent: { + type: 'text', + text + } + } + ]; + + let resp: SimplexChatResponseData | null = null; + + try { + resp = await this.wsService.sendCommand(`/_send @${contactId} json ${JSON.stringify(composed)}`); + } catch (error) { + throw new Error(`SimpleX send failed: ${getErrorMessage(error)}`); + } + + if (resp.type === 'chatCmdError') { + throw new Error(`SimpleX send failed: ${JSON.stringify(resp.chatError ?? resp)}`); + } + } +} diff --git a/backend/src/modules/simplex/services/SimplexChatWsService.ts b/backend/src/modules/simplex/services/SimplexChatWsService.ts new file mode 100644 index 0000000..c476d1e --- /dev/null +++ b/backend/src/modules/simplex/services/SimplexChatWsService.ts @@ -0,0 +1,302 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { randomUUID } from 'crypto'; +import WebSocket from 'ws'; +import { Config } from '../../../types/Config'; +import { getErrorMessage } from '../../../utils/getErrorMessage'; +import { isSet } from '../../../utils/isSet'; +import { PendingCommand } from '../types/PendingCommand'; +import { PendingEvent } from '../types/PendingEvent'; +import { SimplexChatEventTag } from '../types/SimplexChatEventTag'; +import { SimplexChatResponse } from '../types/SimplexChatResponse'; +import { SimplexChatResponseData } from '../types/SimplexChatResponseData'; + +@Injectable() +export class SimplexChatWsService implements OnModuleInit { + private readonly logger = new Logger(SimplexChatWsService.name); + + private readonly commandTimeoutMs = 30_000; + + private readonly pendingCommands = new Map(); + + private readonly pendingEvents: PendingEvent[] = []; + + private ws: WebSocket | null = null; + + private connectPromise: Promise | null = null; + + private reconnectTimer: NodeJS.Timeout | null = null; + + constructor(private readonly configService: ConfigService) {} + + async onModuleInit() { + await this.connectIfNeeded(); + } + + async sendCommand(cmd: string): Promise { + const connected = await this.connectIfNeeded(); + + if (!connected) { + throw new Error('SimpleX WebSocket is not connected'); + } + + return new Promise((resolve, reject) => { + const ws = this.ws; + + if (!ws) { + reject(new Error('SimpleX WebSocket is not connected')); + return; + } + + const corrId = randomUUID(); + + const timer = setTimeout(() => { + this.pendingCommands.delete(corrId); + + reject(new Error(`SimpleX command timed out: ${cmd.slice(0, 80)}`)); + }, this.commandTimeoutMs); + + this.pendingCommands.set(corrId, { resolve, reject, timer }); + + const message = JSON.stringify({ corrId, cmd }); + + ws.send(message, error => { + if (error) { + clearTimeout(timer); + this.pendingCommands.delete(corrId); + reject(error); + } + }); + }); + } + + waitForEvent( + event: SimplexChatEventTag, + timeoutMs: number + ): { promise: Promise; cancel: () => void } { + const pendingEvent: PendingEvent = { + predicate: (resp) => resp.type === event, + resolve: () => {}, + reject: () => {}, + timer: undefined + }; + + const promise = new Promise((resolve, reject) => { + pendingEvent.resolve = resolve; + pendingEvent.reject = reject; + + pendingEvent.timer = setTimeout(() => { + this.removeEventWaiter(pendingEvent); + + reject(new Error('SimpleX connection timed out')); + }, timeoutMs); + + this.pendingEvents.push(pendingEvent); + }); + + const cancel = () => { + this.removeEventWaiter(pendingEvent); + }; + + return { promise, cancel }; + } + + private async connectIfNeeded(): Promise { + if (this.isSocketConnected()) { + return true; + } + + if (!this.connectPromise) { + this.connectPromise = this.connect().finally(() => { + this.connectPromise = null; + }); + } + + try { + await this.connectPromise; + + return this.isSocketConnected(); + } catch (error) { + this.logger.warn(`Failed to connect to SimpleX CLI: ${getErrorMessage(error)}`); + + return false; + } + } + + private connect(): Promise { + const { wsUrl } = this.configService.get('simplex') as Config['simplex']; + + return new Promise((resolve, reject) => { + const socket = new WebSocket(wsUrl); + + const onOpen = () => { + cleanupOpenHandlers(); + this.ws = socket; + this.logger.log(`Connected to SimpleX CLI at ${wsUrl}`); + + resolve(); + }; + + const onError = (error: Error) => { + cleanupOpenHandlers(); + + reject(error); + }; + + const cleanupOpenHandlers = () => { + socket.off('open', onOpen); + socket.off('error', onError); + }; + + socket.once('open', onOpen); + socket.once('error', onError); + + socket.on('message', data => this.onMessage(data)); + socket.on('close', () => this.reconnect()); + socket.on('error', error => { + this.logger.warn(`SimpleX WebSocket error: ${getErrorMessage(error)}`); + }); + }); + } + + private onMessage(raw: WebSocket.RawData): void { + let parsed: SimplexChatResponse; + + try { + parsed = JSON.parse(this.rawMessageToString(raw)) as SimplexChatResponse; + } catch { + this.logger.warn('Received non-JSON message from SimpleX CLI'); + + return; + } + + const { corrId, resp } = parsed; + + if (!resp) { + return; + } + + if (isSet(corrId)) { + this.dispatchCommand(corrId, resp); + + return; + } + + this.dispatchEvent(resp); + } + + private dispatchCommand(corrId: string, resp: SimplexChatResponseData): void { + const pendingCommand = this.pendingCommands.get(corrId); + + if (!pendingCommand) { + return; + } + + clearTimeout(pendingCommand.timer); + this.pendingCommands.delete(corrId); + pendingCommand.resolve(resp); + } + + private dispatchEvent(resp: SimplexChatResponseData): void { + const waiterIndex = this.pendingEvents.findIndex(waiter => waiter.predicate(resp)); + + if (waiterIndex === -1) { + return; + } + + const waiter = this.detachEventWaiterAt(waiterIndex); + + if (!waiter) { + return; + } + + waiter.resolve(resp); + } + + private detachEventWaiterAt(waiterIndex: number): PendingEvent | undefined { + if (waiterIndex === -1) { + return undefined; + } + + const waiter = this.pendingEvents.splice(waiterIndex, 1)[0]; + + if (waiter.timer) { + clearTimeout(waiter.timer); + } + + return waiter; + } + + private removeEventWaiter(waiter: PendingEvent): void { + const waiterIndex = this.pendingEvents.indexOf(waiter); + + if (waiterIndex === -1) { + return; + } + + this.detachEventWaiterAt(waiterIndex); + } + + private reconnect(): void { + this.ws = null; + + this.rejectAllPending('SimpleX WebSocket closed'); + + if (this.reconnectTimer) { + return; + } + + this.logger.warn('SimpleX WebSocket closed; reconnecting in 5s'); + + this.reconnectTimer = setTimeout(() => { + this.clearReconnectTimer(); + void this.connectIfNeeded(); + }, 5000); + } + + private clearReconnectTimer(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + + private rejectAllPending(message: string): void { + for (const pending of this.pendingCommands.values()) { + clearTimeout(pending.timer); + pending.reject(new Error(message)); + } + + this.pendingCommands.clear(); + + for (const waiter of this.pendingEvents) { + if (waiter.timer) { + clearTimeout(waiter.timer); + } + + waiter.reject(new Error(message)); + } + + this.pendingEvents.length = 0; + } + + private isSocketConnected(): boolean { + return this.ws?.readyState === 1; + } + + private rawMessageToString(raw: WebSocket.RawData): string { + if (typeof raw === 'string') { + return raw; + } + + if (Buffer.isBuffer(raw)) { + return raw.toString('utf8'); + } + + if (Array.isArray(raw)) { + return Buffer.concat(raw).toString('utf8'); + } + + return Buffer.from(raw).toString('utf8'); + } +} diff --git a/backend/src/modules/simplex/types/ConnectOutcome.ts b/backend/src/modules/simplex/types/ConnectOutcome.ts new file mode 100644 index 0000000..e188db1 --- /dev/null +++ b/backend/src/modules/simplex/types/ConnectOutcome.ts @@ -0,0 +1,2 @@ +export type ConnectOutcome = + { kind: 'connected'; contactId: number } | { kind: 'pending' } | { kind: 'error'; message: string }; diff --git a/backend/src/modules/simplex/types/PendingCommand.ts b/backend/src/modules/simplex/types/PendingCommand.ts new file mode 100644 index 0000000..c38a46a --- /dev/null +++ b/backend/src/modules/simplex/types/PendingCommand.ts @@ -0,0 +1,7 @@ +import { SimplexChatResponseData } from './SimplexChatResponseData'; + +export type PendingCommand = { + resolve: (resp: SimplexChatResponseData) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; +}; diff --git a/backend/src/modules/simplex/types/PendingEvent.ts b/backend/src/modules/simplex/types/PendingEvent.ts new file mode 100644 index 0000000..83734c1 --- /dev/null +++ b/backend/src/modules/simplex/types/PendingEvent.ts @@ -0,0 +1,8 @@ +import { SimplexChatResponseData } from './SimplexChatResponseData'; + +export type PendingEvent = { + predicate: (resp: SimplexChatResponseData) => boolean; + resolve: (resp: SimplexChatResponseData) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout | undefined; +}; diff --git a/backend/src/modules/simplex/types/SimplexAgentErrorType.ts b/backend/src/modules/simplex/types/SimplexAgentErrorType.ts new file mode 100644 index 0000000..d1b199a --- /dev/null +++ b/backend/src/modules/simplex/types/SimplexAgentErrorType.ts @@ -0,0 +1,9 @@ +export type SimplexAgentErrorType = + | { + type: 'SMP'; + serverAddress: string; + smpErr: unknown; + } + | { + type: string; + }; diff --git a/backend/src/modules/simplex/types/SimplexChatError.ts b/backend/src/modules/simplex/types/SimplexChatError.ts new file mode 100644 index 0000000..428318d --- /dev/null +++ b/backend/src/modules/simplex/types/SimplexChatError.ts @@ -0,0 +1,29 @@ +import type { SimplexAgentErrorType } from './SimplexAgentErrorType'; +import type { SimplexChatErrorTag } from './SimplexChatErrorTag'; + +export type SimplexChatError = + | { + type: 'error'; + errorType: unknown; + } + | { + type: 'errorAgent'; + agentError: SimplexAgentErrorType; + agentConnId: string; + } + | { + type: 'errorStore'; + storeError: unknown; + }; + +export const isSimplexChatError = (value: unknown): value is SimplexChatError => { + if (typeof value !== 'object' || value === null || !('type' in value)) { + return false; + } + + const simplexChatErrorTags = new Set(['error', 'errorAgent', 'errorStore']); + + const { type: tag } = value; + + return typeof tag === 'string' && simplexChatErrorTags.has(tag as SimplexChatErrorTag); +}; diff --git a/backend/src/modules/simplex/types/SimplexChatErrorTag.ts b/backend/src/modules/simplex/types/SimplexChatErrorTag.ts new file mode 100644 index 0000000..97c8a01 --- /dev/null +++ b/backend/src/modules/simplex/types/SimplexChatErrorTag.ts @@ -0,0 +1 @@ +export type SimplexChatErrorTag = 'error' | 'errorAgent' | 'errorStore'; diff --git a/backend/src/modules/simplex/types/SimplexChatEventTag.ts b/backend/src/modules/simplex/types/SimplexChatEventTag.ts new file mode 100644 index 0000000..06fb258 --- /dev/null +++ b/backend/src/modules/simplex/types/SimplexChatEventTag.ts @@ -0,0 +1 @@ +export type SimplexChatEventTag = 'contactConnected'; diff --git a/backend/src/modules/simplex/types/SimplexChatResponse.ts b/backend/src/modules/simplex/types/SimplexChatResponse.ts new file mode 100644 index 0000000..a575ce1 --- /dev/null +++ b/backend/src/modules/simplex/types/SimplexChatResponse.ts @@ -0,0 +1,6 @@ +import { SimplexChatResponseData } from './SimplexChatResponseData'; + +export type SimplexChatResponse = { + corrId?: string | null; + resp: SimplexChatResponseData; +}; diff --git a/backend/src/modules/simplex/types/SimplexChatResponseData.ts b/backend/src/modules/simplex/types/SimplexChatResponseData.ts new file mode 100644 index 0000000..7e1b4b8 --- /dev/null +++ b/backend/src/modules/simplex/types/SimplexChatResponseData.ts @@ -0,0 +1,11 @@ +import type { SimplexConnectionPlan } from './SimplexConnectionPlan'; +import type { SimplexChatError } from './SimplexChatError'; +import type { SimplexChatResponseTag } from './SimplexChatResponseTag'; +import type { SimplexContactRef } from './SimplexContactRef'; + +export type SimplexChatResponseData = { + type: SimplexChatResponseTag; + connectionPlan?: SimplexConnectionPlan; + chatError?: SimplexChatError; + contact?: SimplexContactRef; +}; diff --git a/backend/src/modules/simplex/types/SimplexChatResponseTag.ts b/backend/src/modules/simplex/types/SimplexChatResponseTag.ts new file mode 100644 index 0000000..3177dee --- /dev/null +++ b/backend/src/modules/simplex/types/SimplexChatResponseTag.ts @@ -0,0 +1,8 @@ +import type { SimplexChatEventTag } from './SimplexChatEventTag'; + +export type SimplexChatResponseTag = + | SimplexChatEventTag + | 'chatCmdError' + | 'connectionPlan' + | 'sentConfirmation' + | 'sentInvitation'; diff --git a/backend/src/modules/simplex/types/SimplexConnectionPlan.ts b/backend/src/modules/simplex/types/SimplexConnectionPlan.ts new file mode 100644 index 0000000..8f1e445 --- /dev/null +++ b/backend/src/modules/simplex/types/SimplexConnectionPlan.ts @@ -0,0 +1,6 @@ +import type { SimplexContactAddressPlan } from './SimplexContactAddressPlan'; + +export type SimplexConnectionPlan = { + type: 'contactAddress'; + contactAddressPlan?: SimplexContactAddressPlan; +}; diff --git a/backend/src/modules/simplex/types/SimplexContactAddressPlan.ts b/backend/src/modules/simplex/types/SimplexContactAddressPlan.ts new file mode 100644 index 0000000..04bd7c5 --- /dev/null +++ b/backend/src/modules/simplex/types/SimplexContactAddressPlan.ts @@ -0,0 +1,6 @@ +import type { SimplexContactRef } from './SimplexContactRef'; + +export type SimplexContactAddressPlan = { + type: 'known'; + contact?: SimplexContactRef; +}; diff --git a/backend/src/modules/simplex/types/SimplexContactRef.ts b/backend/src/modules/simplex/types/SimplexContactRef.ts new file mode 100644 index 0000000..d4ace07 --- /dev/null +++ b/backend/src/modules/simplex/types/SimplexContactRef.ts @@ -0,0 +1,3 @@ +export type SimplexContactRef = { + contactId: number; +}; diff --git a/backend/src/modules/simplex/utils/formatSimplexConnectError.ts b/backend/src/modules/simplex/utils/formatSimplexConnectError.ts new file mode 100644 index 0000000..daa6006 --- /dev/null +++ b/backend/src/modules/simplex/utils/formatSimplexConnectError.ts @@ -0,0 +1,26 @@ +import { isSimplexChatError, type SimplexChatError } from '../types/SimplexChatError'; + +const defaultMessage = 'Failed to connect to SimpleX. Check your contact link and try again.'; + +export const formatSimplexConnectError = (chatError: unknown): string => { + if (!isSimplexChatError(chatError)) { + return defaultMessage; + } + + return formatKnownChatError(chatError); +}; + +const formatKnownChatError = (chatError: SimplexChatError): string => { + switch (chatError.type) { + case 'errorAgent': + if (chatError.agentError.type === 'SMP') { + return 'Could not reach the SimpleX server for that address. Check the link and try again.'; + } + + return 'SimpleX could not establish a connection. Check your contact link and try again.'; + case 'errorStore': + return 'SimpleX could not complete the connection. Check the link and try again.'; + case 'error': + return defaultMessage; + } +}; diff --git a/backend/src/modules/simplex/utils/parseSimplexConnectResponse.ts b/backend/src/modules/simplex/utils/parseSimplexConnectResponse.ts new file mode 100644 index 0000000..328eb9d --- /dev/null +++ b/backend/src/modules/simplex/utils/parseSimplexConnectResponse.ts @@ -0,0 +1,34 @@ +import type { ConnectOutcome } from '../types/ConnectOutcome'; +import type { SimplexChatResponseData } from '../types/SimplexChatResponseData'; +import { formatSimplexConnectError } from './formatSimplexConnectError'; + +export const parseSimplexConnectResponse = (resp: SimplexChatResponseData): ConnectOutcome => { + if (resp.type === 'chatCmdError') { + return { + kind: 'error', + message: formatSimplexConnectError(resp.chatError) + }; + } + + if (resp.type === 'connectionPlan') { + const plan = resp.connectionPlan?.contactAddressPlan; + + if (plan?.type === 'known' && plan.contact) { + return { + kind: 'connected', + contactId: plan.contact.contactId + }; + } + + return { kind: 'pending' }; + } + + if (resp.type === 'sentInvitation' || resp.type === 'sentConfirmation') { + return { kind: 'pending' }; + } + + return { + kind: 'error', + message: 'Unexpected response from SimpleX while connecting. Check your contact link and try again.' + }; +}; diff --git a/backend/src/modules/storefrontCart/StorefrontCartModule.ts b/backend/src/modules/storefrontCart/StorefrontCartModule.ts new file mode 100644 index 0000000..9635bdc --- /dev/null +++ b/backend/src/modules/storefrontCart/StorefrontCartModule.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { DiscountCodesModule } from '../discountCode/DiscountCodesModule'; +import { ProductsModule } from '../product/ProductsModule'; +import { StorefrontCoreModule } from '../storefrontCore/StorefrontCoreModule'; +import { StorefrontProductModule } from '../storefrontProduct/StorefrontProductModule'; +import { XmrRateModule } from '../xmrRate/XmrRateModule'; +import { StorefrontCartController } from './controllers/StorefrontCartController'; +import { StorefrontCartDiscountResolver } from './services/StorefrontCartDiscountResolver'; +import { StorefrontCartService } from './services/StorefrontCartService'; +import { StorefrontDiscountService } from './services/StorefrontDiscountService'; + +@Module({ + imports: [StorefrontCoreModule, StorefrontProductModule, ProductsModule, DiscountCodesModule, XmrRateModule], + controllers: [StorefrontCartController], + providers: [StorefrontCartService, StorefrontDiscountService, StorefrontCartDiscountResolver], + exports: [StorefrontCartService] +}) +export class StorefrontCartModule {} diff --git a/backend/src/modules/storefrontCart/controllers/StorefrontCartController.ts b/backend/src/modules/storefrontCart/controllers/StorefrontCartController.ts new file mode 100644 index 0000000..d2046c8 --- /dev/null +++ b/backend/src/modules/storefrontCart/controllers/StorefrontCartController.ts @@ -0,0 +1,178 @@ +import { Body, Controller, Get, HttpStatus, Post, Req, Res, UseFilters } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import type { Request, Response } from 'express'; +import { throttleProfiles } from '../../../config/throttleProfiles'; +import { StorefrontExceptionFilter } from '../../storefrontCore/filters/StorefrontExceptionFilter'; +import { StorefrontCartCookieService } from '../../storefrontCore/services/StorefrontCartCookieService'; +import { StorefrontCheckoutSessionCookieService } from '../../storefrontCore/services/StorefrontCheckoutSessionCookieService'; +import { StorefrontDiscountCookieService } from '../../storefrontCore/services/StorefrontDiscountCookieService'; +import { StorefrontFeedbackCookieService } from '../../storefrontCore/services/StorefrontFeedbackCookieService'; +import { StorefrontCaptchaCookieService } from '../../storefrontCore/services/StorefrontCaptchaCookieService'; +import { StorefrontCaptchaService } from '../../storefrontCore/services/StorefrontCaptchaService'; +import { StorefrontShopViewService } from '../../storefrontCore/services/StorefrontShopViewService'; +import { safeInternalShopRedirectPath } from '../../../utils/safeInternalShopRedirectPath'; +import { AddToCartDto } from '../dto/AddToCartDto'; +import { ApplyDiscountCodeDto } from '../dto/ApplyDiscountCodeDto'; +import { RemoveDiscountCodeDto } from '../dto/RemoveDiscountCodeDto'; +import { RemoveFromCartDto } from '../dto/RemoveFromCartDto'; +import { UpdateCartQtyDto } from '../dto/UpdateCartQtyDto'; +import { StorefrontCartService } from '../services/StorefrontCartService'; +import { StorefrontDiscountService } from '../services/StorefrontDiscountService'; +import type { CartCookieMutation } from '../types/CartCookieMutation'; +import type { DiscountCookieMutation } from '../types/DiscountCookieMutation'; + +@Controller() +@UseFilters(StorefrontExceptionFilter) +export class StorefrontCartController { + constructor( + private readonly cartService: StorefrontCartService, + private readonly discountService: StorefrontDiscountService, + private readonly shopViewService: StorefrontShopViewService, + private readonly cartCookieService: StorefrontCartCookieService, + private readonly discountCookieService: StorefrontDiscountCookieService, + private readonly feedbackCookieService: StorefrontFeedbackCookieService, + private readonly checkoutSessionCookieService: StorefrontCheckoutSessionCookieService, + private readonly captchaService: StorefrontCaptchaService, + private readonly captchaCookieService: StorefrontCaptchaCookieService + ) {} + + @Get('shop/cart') + @Throttle(throttleProfiles.cartPage) + async cartSummary(@Req() req: Request, @Res() res: Response) { + const checkoutSessionId = this.checkoutSessionCookieService.getSessionId(req, res); + + if (checkoutSessionId) { + res.redirect(HttpStatus.FOUND, '/shop/checkout'); + + return; + } + + const cart = this.cartCookieService.getCart(req, res); + const discountCodes = this.discountCookieService.getDiscountCodes(req, res); + + const [summary, shopLocals] = await Promise.all([ + this.cartService.getCartSummary(cart, discountCodes), + this.shopViewService.buildShopRenderLocals(req, res, { + title: 'Cart', + metaDescription: 'Review your cart at {shopName}.' + }) + ]); + + const { svg: captchaSvg, encryptedAnswer } = this.captchaService.create(); + + this.captchaCookieService.setAnswer(req, res, encryptedAnswer); + + return res.render('cart-summary', { + ...summary, + ...shopLocals, + captchaSvg + }); + } + + @Post('shop/cart/product') + async addToCart( + @Req() req: Request, + @Res() res: Response, + @Body() { variantId, qty }: AddToCartDto + ): Promise { + const cart = this.cartCookieService.getCart(req, res); + + const cartMutation = await this.cartService.addToCart(cart, variantId, qty); + + this.applyCartCookieMutation(req, res, cartMutation); + + this.feedbackCookieService.setFeedback(req, res, { + type: 'success', + text: 'Added to cart.' + }); + + res.redirect(HttpStatus.FOUND, safeInternalShopRedirectPath(req)); + } + + @Post('shop/cart/product/update') + async updateCartQty( + @Req() req: Request, + @Res() res: Response, + @Body() { variantId, qty }: UpdateCartQtyDto + ): Promise { + const cart = this.cartCookieService.getCart(req, res); + + const cartMutation = await this.cartService.updateCartQty(cart, variantId, qty); + + this.applyCartCookieMutation(req, res, cartMutation); + + this.feedbackCookieService.setFeedback(req, res, { + type: 'success', + text: 'Cart updated.' + }); + + res.redirect(HttpStatus.FOUND, '/shop/cart'); + } + + @Post('shop/cart/product/remove') + removeFromCart(@Req() req: Request, @Res() res: Response, @Body() { variantId }: RemoveFromCartDto): void { + const cart = this.cartCookieService.getCart(req, res); + + const cartMutation = this.cartService.removeFromCart(cart, variantId); + + this.applyCartCookieMutation(req, res, cartMutation); + + this.feedbackCookieService.setFeedback(req, res, { type: 'success', text: 'Item removed.' }); + + res.redirect(HttpStatus.FOUND, '/shop/cart'); + } + + @Post('shop/cart/discount') + @Throttle(throttleProfiles.cartDiscount) + async applyDiscount( + @Req() req: Request, + @Res() res: Response, + @Body() { code }: ApplyDiscountCodeDto + ): Promise { + const cart = this.cartCookieService.getCart(req, res); + const discountCodes = this.discountCookieService.getDiscountCodes(req, res); + + const nextDiscountCodes = await this.cartService.applyDiscountCode(cart, discountCodes, code); + + this.applyDiscountCookieMutation(req, res, nextDiscountCodes); + + this.feedbackCookieService.setFeedback(req, res, { + type: 'success', + text: 'Discount code applied.' + }); + + res.redirect(HttpStatus.FOUND, '/shop/cart'); + } + + @Post('shop/cart/discount/remove') + removeDiscount(@Req() req: Request, @Res() res: Response, @Body() { code }: RemoveDiscountCodeDto): void { + const discountCodes = this.discountCookieService.getDiscountCodes(req, res); + const discountMutation = this.discountService.removeDiscountCode(discountCodes, code); + + this.applyDiscountCookieMutation(req, res, discountMutation); + + this.feedbackCookieService.setFeedback(req, res, { + type: 'success', + text: 'Discount code removed.' + }); + + res.redirect(HttpStatus.FOUND, '/shop/cart'); + } + + private applyDiscountCookieMutation(req: Request, res: Response, discountMutation: DiscountCookieMutation): void { + if (discountMutation === null) { + this.discountCookieService.clearDiscount(req, res); + } else { + this.discountCookieService.setDiscountCodes(req, res, discountMutation); + } + } + + private applyCartCookieMutation(req: Request, res: Response, cartMutation: CartCookieMutation): void { + if (cartMutation === null) { + this.cartCookieService.clearCart(req, res); + this.discountCookieService.clearDiscount(req, res); + } else { + this.cartCookieService.setCart(req, res, cartMutation); + } + } +} diff --git a/backend/src/modules/storefrontCart/dto/AddToCartDto.ts b/backend/src/modules/storefrontCart/dto/AddToCartDto.ts new file mode 100644 index 0000000..bd35160 --- /dev/null +++ b/backend/src/modules/storefrontCart/dto/AddToCartDto.ts @@ -0,0 +1,12 @@ +import { IsInt, IsNotEmpty, IsUUID, Min } from 'class-validator'; + +export class AddToCartDto { + @IsNotEmpty() + @IsUUID() + variantId: string; + + @IsNotEmpty() + @IsInt() + @Min(1) + qty: number; +} diff --git a/backend/src/modules/storefrontCart/dto/ApplyDiscountCodeDto.ts b/backend/src/modules/storefrontCart/dto/ApplyDiscountCodeDto.ts new file mode 100644 index 0000000..87e08a2 --- /dev/null +++ b/backend/src/modules/storefrontCart/dto/ApplyDiscountCodeDto.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; +import { getAppConfig } from '../../../config'; + +const { + validation: { discountCodeMaxLength } +} = getAppConfig(); + +export class ApplyDiscountCodeDto { + @IsNotEmpty() + @IsString() + @MaxLength(discountCodeMaxLength) + code: string; +} diff --git a/backend/src/modules/storefrontCart/dto/CookieCartLineDto.ts b/backend/src/modules/storefrontCart/dto/CookieCartLineDto.ts new file mode 100644 index 0000000..ccd8ee0 --- /dev/null +++ b/backend/src/modules/storefrontCart/dto/CookieCartLineDto.ts @@ -0,0 +1,13 @@ +import { IsInt, IsNotEmpty, IsUUID, Min } from 'class-validator'; +import type { CookieCartLine } from '../../storefrontCore/types/cart/CookieCartLine'; + +export class CookieCartLineDto implements CookieCartLine { + @IsNotEmpty() + @IsUUID() + variantId: string; + + @IsNotEmpty() + @IsInt() + @Min(1) + qty: number; +} diff --git a/backend/src/modules/storefrontCart/dto/RemoveDiscountCodeDto.ts b/backend/src/modules/storefrontCart/dto/RemoveDiscountCodeDto.ts new file mode 100644 index 0000000..7cfad06 --- /dev/null +++ b/backend/src/modules/storefrontCart/dto/RemoveDiscountCodeDto.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; +import { getAppConfig } from '../../../config'; + +const { + validation: { discountCodeMaxLength } +} = getAppConfig(); + +export class RemoveDiscountCodeDto { + @IsNotEmpty() + @IsString() + @MaxLength(discountCodeMaxLength) + code: string; +} diff --git a/backend/src/modules/storefrontCart/dto/RemoveFromCartDto.ts b/backend/src/modules/storefrontCart/dto/RemoveFromCartDto.ts new file mode 100644 index 0000000..7000248 --- /dev/null +++ b/backend/src/modules/storefrontCart/dto/RemoveFromCartDto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsUUID } from 'class-validator'; + +export class RemoveFromCartDto { + @IsNotEmpty() + @IsUUID() + variantId: string; +} diff --git a/backend/src/modules/storefrontCart/dto/UpdateCartQtyDto.ts b/backend/src/modules/storefrontCart/dto/UpdateCartQtyDto.ts new file mode 100644 index 0000000..12f757b --- /dev/null +++ b/backend/src/modules/storefrontCart/dto/UpdateCartQtyDto.ts @@ -0,0 +1,12 @@ +import { IsInt, IsNotEmpty, IsUUID, Min } from 'class-validator'; + +export class UpdateCartQtyDto { + @IsNotEmpty() + @IsUUID() + variantId: string; + + @IsNotEmpty() + @IsInt() + @Min(0) + qty: number; +} diff --git a/backend/src/modules/storefrontCart/services/StorefrontCartDiscountResolver.spec.ts b/backend/src/modules/storefrontCart/services/StorefrontCartDiscountResolver.spec.ts new file mode 100644 index 0000000..b472f6a --- /dev/null +++ b/backend/src/modules/storefrontCart/services/StorefrontCartDiscountResolver.spec.ts @@ -0,0 +1,283 @@ +import type { ConfigService } from '@nestjs/config'; +import { DiscountCode } from '../../discountCode/entities/DiscountCode'; +import type { DiscountCodesService } from '../../discountCode/services/DiscountCodesService'; +import { DiscountType } from '../../discountCode/types/DiscountType'; +import type { ProductsService } from '../../product/services/ProductsService'; +import { DeliveryMode } from '../../product/types/DeliveryMode'; +import type { ProductVariantsService } from '../../product/services/ProductVariantsService'; +import type { CookieCartLineExtended } from '../types/CookieCartLineExtended'; +import { StorefrontCartDiscountResolver } from './StorefrontCartDiscountResolver'; + +const buildDiscountCode = (overrides: Partial = {}): DiscountCode => + ({ + id: 'discount-1', + code: 'SAVE10', + type: DiscountType.Percent, + value: 10, + isActive: true, + validFrom: null, + validUntil: null, + maxRedemptions: null, + redemptionCount: 0, + minOrderAmount: null, + isExclusive: false, + products: [], + categories: [], + variants: [], + ...overrides + }) as DiscountCode; + +const buildCartLine = (overrides: Partial = {}): CookieCartLineExtended => + ({ + id: 'variant-1', + productId: 'product-1', + productTitle: 'Product', + title: 'Variant', + price: 100, + deliveryMode: DeliveryMode.Auto, + stockAvailable: 10, + stockForSession: 10, + thumbnailUrl: null, + images: [], + qty: 1, + lineSubtotal: 100, + stockIssueMessage: null, + ...overrides + }) as CookieCartLineExtended; + +describe('StorefrontCartDiscountResolver', () => { + let resolver: StorefrontCartDiscountResolver; + let discountCodesService: { + normalizeCode: jest.Mock; + findByNormalizedCodes: jest.Mock; + }; + let productsService: { + findProductIdsByCategoryIds: jest.Mock; + }; + let productVariantsService: { + findVariantIdsByProductIds: jest.Mock; + }; + let configService: { + get: jest.Mock; + }; + + beforeEach(() => { + discountCodesService = { + normalizeCode: jest.fn((code: string) => code.trim().toUpperCase()), + findByNormalizedCodes: jest.fn().mockResolvedValue([]) + }; + + productsService = { + findProductIdsByCategoryIds: jest.fn().mockResolvedValue(new Map()) + }; + + productVariantsService = { + findVariantIdsByProductIds: jest.fn().mockResolvedValue(new Map()) + }; + + configService = { + get: jest.fn().mockReturnValue({ shopFiatCurrency: 'USD' }) + }; + + resolver = new StorefrontCartDiscountResolver( + discountCodesService as unknown as DiscountCodesService, + configService as unknown as ConfigService, + productsService as unknown as ProductsService, + productVariantsService as unknown as ProductVariantsService + ); + }); + + it('returns an empty discount state when no codes are provided', async () => { + const result = await resolver.resolve([buildCartLine()], []); + + expect(result).toEqual({ + discounts: [], + cartDiscountTotal: 0, + cartTotalPrice: 100 + }); + }); + + it('marks unknown codes as invalid', async () => { + discountCodesService.findByNormalizedCodes.mockResolvedValue([]); + + const result = await resolver.resolve([buildCartLine()], ['missing']); + + expect(result.discounts).toEqual([ + { + code: 'MISSING', + amount: null, + issueMessage: 'Invalid discount code', + ineligibleVariantIds: undefined + } + ]); + expect(result.cartDiscountTotal).toBe(0); + expect(result.cartTotalPrice).toBe(100); + }); + + it('rejects inactive, expired, and not-yet-valid codes', async () => { + discountCodesService.findByNormalizedCodes.mockResolvedValue([ + buildDiscountCode({ id: 'inactive', code: 'INACTIVE', isActive: false }), + buildDiscountCode({ + id: 'expired', + code: 'EXPIRED', + validUntil: new Date('2020-01-01T00:00:00.000Z') + }), + buildDiscountCode({ + id: 'future', + code: 'FUTURE', + validFrom: new Date('2099-01-01T00:00:00.000Z') + }) + ]); + + const result = await resolver.resolve([buildCartLine()], ['inactive', 'expired', 'future']); + + expect(result.discounts.map(d => d.issueMessage)).toEqual([ + 'This discount code is not active', + 'This discount code has expired', + 'This discount code is not valid yet' + ]); + expect(result.cartDiscountTotal).toBe(0); + }); + + it('rejects codes that reached their redemption limit or fail minimum order amount', async () => { + discountCodesService.findByNormalizedCodes.mockResolvedValue([ + buildDiscountCode({ + id: 'limit', + code: 'LIMIT', + maxRedemptions: 5, + redemptionCount: 5 + }), + buildDiscountCode({ + id: 'min', + code: 'MIN50', + minOrderAmount: 50 + }) + ]); + + const result = await resolver.resolve([buildCartLine({ lineSubtotal: 40 })], ['limit', 'min50']); + + expect(result.discounts[0].issueMessage).toBe('This discount code has reached its usage limit'); + expect(result.discounts[1].issueMessage).toBe('Minimum order amount for this code is 50 USD'); + }); + + it('applies percent discounts before fixed discounts and uses higher values within each type', async () => { + discountCodesService.findByNormalizedCodes.mockResolvedValue([ + buildDiscountCode({ id: 'fixed-small', code: 'FIX5', type: DiscountType.Fixed, value: 5 }), + buildDiscountCode({ id: 'percent-small', code: 'PCT10', type: DiscountType.Percent, value: 10 }), + buildDiscountCode({ id: 'percent-large', code: 'PCT20', type: DiscountType.Percent, value: 20 }) + ]); + + const result = await resolver.resolve([buildCartLine({ lineSubtotal: 100 })], ['fix5', 'pct10', 'pct20']); + + expect(result.discounts.filter(d => d.amount !== null).map(d => d.code)).toEqual(['PCT20', 'PCT10', 'FIX5']); + expect(result.cartDiscountTotal).toBe(33); + expect(result.cartTotalPrice).toBe(67); + }); + + it('applies sequential percent discounts to the remaining subtotal', async () => { + discountCodesService.findByNormalizedCodes.mockResolvedValue([ + buildDiscountCode({ id: 'pct-1', code: 'PCT10A', type: DiscountType.Percent, value: 10 }), + buildDiscountCode({ id: 'pct-2', code: 'PCT10B', type: DiscountType.Percent, value: 10 }) + ]); + + const result = await resolver.resolve([buildCartLine({ lineSubtotal: 100 })], ['pct10a', 'pct10b']); + + expect(result.cartDiscountTotal).toBe(19); + expect(result.cartTotalPrice).toBe(81); + }); + + it('rejects exclusive codes that cannot be combined with other discounts', async () => { + discountCodesService.findByNormalizedCodes.mockResolvedValue([ + buildDiscountCode({ id: 'base', code: 'BASE', type: DiscountType.Fixed, value: 5 }), + buildDiscountCode({ id: 'exclusive', code: 'ONLY', isExclusive: true, type: DiscountType.Fixed, value: 10 }) + ]); + + const exclusiveSecond = await resolver.resolve([buildCartLine()], ['base', 'only']); + const exclusiveFirst = await resolver.resolve([buildCartLine()], ['only', 'base']); + + expect(exclusiveSecond.discounts.find(d => d.code === 'ONLY')?.issueMessage).toBe( + 'This code cannot be combined with other discounts' + ); + expect(exclusiveFirst.discounts.find(d => d.code === 'BASE')?.issueMessage).toBe( + 'This code cannot be combined with other discounts' + ); + expect(exclusiveFirst.discounts.find(d => d.code === 'ONLY')?.amount).toBe(10); + }); + + it('rejects scoped codes when the cart contains ineligible variants', async () => { + discountCodesService.findByNormalizedCodes.mockResolvedValue([ + buildDiscountCode({ + id: 'scoped', + code: 'SCOPED', + variants: [{ id: 'allowed-variant' } as DiscountCode['variants'][number]] + }) + ]); + + const result = await resolver.resolve( + [buildCartLine({ id: 'other-variant' }), buildCartLine({ id: 'allowed-variant', lineSubtotal: 50 })], + ['scoped'] + ); + + expect(result.discounts[0]).toEqual( + expect.objectContaining({ + code: 'SCOPED', + amount: null, + issueMessage: 'This code only applies when your cart contains only selected products', + ineligibleVariantIds: ['other-variant'] + }) + ); + }); + + it('applies a variant-scoped code when the cart only contains eligible variants', async () => { + discountCodesService.findByNormalizedCodes.mockResolvedValue([ + buildDiscountCode({ + id: 'scoped', + code: 'SCOPED', + type: DiscountType.Fixed, + value: 15, + variants: [{ id: 'allowed-variant' } as DiscountCode['variants'][number]] + }) + ]); + + const result = await resolver.resolve([buildCartLine({ id: 'allowed-variant', lineSubtotal: 100 })], ['scoped']); + + expect(result.discounts[0]).toEqual( + expect.objectContaining({ + code: 'SCOPED', + amount: 15, + issueMessage: null + }) + ); + expect(result.cartTotalPrice).toBe(85); + }); + + it('applies a category-scoped code to variants from products in that category', async () => { + discountCodesService.findByNormalizedCodes.mockResolvedValue([ + buildDiscountCode({ + id: 'category', + code: 'CAT10', + type: DiscountType.Percent, + value: 10, + categories: [{ id: 'category-1' } as DiscountCode['categories'][number]] + }) + ]); + productsService.findProductIdsByCategoryIds.mockResolvedValue(new Map([['category-1', ['product-1']]])); + productVariantsService.findVariantIdsByProductIds.mockResolvedValue(new Map([['product-1', ['variant-1']]])); + + const result = await resolver.resolve([buildCartLine({ id: 'variant-1', lineSubtotal: 50 })], ['cat10']); + + expect(result.discounts[0].amount).toBe(5); + expect(result.cartTotalPrice).toBe(45); + }); + + it('floors the cart total at zero when discounts exceed the subtotal', async () => { + discountCodesService.findByNormalizedCodes.mockResolvedValue([ + buildDiscountCode({ id: 'big-fixed', code: 'BIG', type: DiscountType.Fixed, value: 150 }) + ]); + + const result = await resolver.resolve([buildCartLine({ lineSubtotal: 100 })], ['big']); + + expect(result.cartDiscountTotal).toBe(100); + expect(result.cartTotalPrice).toBe(0); + }); +}); diff --git a/backend/src/modules/storefrontCart/services/StorefrontCartDiscountResolver.ts b/backend/src/modules/storefrontCart/services/StorefrontCartDiscountResolver.ts new file mode 100644 index 0000000..954e525 --- /dev/null +++ b/backend/src/modules/storefrontCart/services/StorefrontCartDiscountResolver.ts @@ -0,0 +1,299 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import dayjs from '../../../plugins/dayjs'; +import Decimal from 'decimal.js'; +import type { Config } from '../../../types/Config'; +import { DiscountCodesService } from '../../discountCode/services/DiscountCodesService'; +import { DiscountCode } from '../../discountCode/entities/DiscountCode'; +import { DiscountType } from '../../discountCode/types/DiscountType'; +import { ProductsService } from '../../product/services/ProductsService'; +import { ProductVariantsService } from '../../product/services/ProductVariantsService'; +import type { ComputedCartDiscount } from '../types/ComputedCartDiscount'; +import type { CartDiscountResolved } from '../types/CartDiscountResolved'; +import type { CodeIssue } from '../types/CodeIssue'; +import type { CookieCartExtended } from '../types/CookieCartExtended'; +import type { StorefrontCartDiscountState } from '../types/StorefrontCartDiscountState'; +import { sumByKey } from '../../../utils/sumByKey'; +import { getRedemptionLimitIssue } from '../utils/getRedemptionLimitIssue'; + +@Injectable() +export class StorefrontCartDiscountResolver { + constructor( + private readonly discountCodesService: DiscountCodesService, + private readonly configService: ConfigService, + private readonly productsService: ProductsService, + private readonly productVariantsService: ProductVariantsService + ) {} + + async resolve(cartExtended: CookieCartExtended, codes: string[]): Promise { + const cartSubtotal = sumByKey(cartExtended, 'lineSubtotal'); + const cartVariantIds = cartExtended.map(line => line.id); + const normalizedCodes = codes.map(c => this.discountCodesService.normalizeCode(c)); + + if (normalizedCodes.length === 0) { + return { + discounts: [], + cartDiscountTotal: 0, + cartTotalPrice: Math.max(0, cartSubtotal) + }; + } + + const entities = await this.discountCodesService.findByNormalizedCodes(normalizedCodes); + + const byCode = new Map(entities.map(e => [e.code, e])); + const allowedVariantIdsByDiscountId = await this.getAllowedVariantsPerDiscount(entities); + + const issueByCode = new Map(); + const accepted: DiscountCode[] = []; + + for (const code of normalizedCodes) { + const entity = byCode.get(code); + + if (!entity) { + issueByCode.set(code, { message: 'Invalid discount code' }); + continue; + } + + const issue = this.getCodeIssue( + entity, + cartSubtotal, + cartVariantIds, + accepted, + allowedVariantIdsByDiscountId.get(entity.id) ?? null + ); + + if (issue) { + issueByCode.set(code, issue); + continue; + } + + accepted.push(entity); + } + + const orderedForApplication = this.sortForDiscountApplication(accepted); + const applyingCodes = orderedForApplication.map(e => e.code); + + const applyingCodeSet = new Set(applyingCodes); + + const displayOrderCodes = [ + ...applyingCodes, + ...normalizedCodes.filter(c => issueByCode.has(c) && !applyingCodeSet.has(c)) + ]; + + const computedDiscounts = this.computeCartDiscounts(cartSubtotal, orderedForApplication); + const amountByCode = new Map(computedDiscounts.map(d => [d.code, d.amount])); + + const discounts: CartDiscountResolved[] = displayOrderCodes.map(code => { + const issue = issueByCode.get(code); + + return { + code, + amount: amountByCode.get(code) ?? null, + issueMessage: issue?.message ?? null, + ineligibleVariantIds: issue?.ineligibleVariantIds + }; + }); + + const discountAmount = sumByKey(computedDiscounts, 'amount'); + + const cartTotalPrice = new Decimal(cartSubtotal).minus(discountAmount).toDecimalPlaces(2).toNumber(); + + return { + discounts, + cartDiscountTotal: discountAmount, + cartTotalPrice: Math.max(0, cartTotalPrice) + }; + } + + private async getAllowedVariantsPerDiscount(entities: DiscountCode[]): Promise | null>> { + const categoryIdsSet = new Set(); + + for (const entity of entities) { + for (const category of entity.categories) { + categoryIdsSet.add(category.id); + } + } + + const categoryIds = [...categoryIdsSet]; + + const productIdsByCategoryId = await this.productsService.findProductIdsByCategoryIds(categoryIds); + + const allProductIdsSet = new Set(); + + for (const entity of entities) { + for (const product of entity.products) { + allProductIdsSet.add(product.id); + } + + for (const category of entity.categories) { + for (const productId of productIdsByCategoryId.get(category.id) ?? []) { + allProductIdsSet.add(productId); + } + } + } + + const allProductIds = [...allProductIdsSet]; + + const variantIdsByProductId = await this.productVariantsService.findVariantIdsByProductIds(allProductIds); + + return new Map( + entities.map(entity => [ + entity.id, + this.getAllowedVariantIdsForDiscount(entity, productIdsByCategoryId, variantIdsByProductId) + ]) + ); + } + + private getAllowedVariantIdsForDiscount( + discount: DiscountCode, + productIdsByCategoryId: Map, + variantIdsByProductId: Map + ): Set | null { + const { products, variants, categories } = discount; + + if (products.length === 0 && variants.length === 0 && categories.length === 0) { + return null; + } + + const allowed = new Set(); + + for (const variant of variants) { + allowed.add(variant.id); + } + + for (const product of products) { + for (const variantId of variantIdsByProductId.get(product.id) ?? []) { + allowed.add(variantId); + } + } + + for (const category of categories) { + for (const productId of productIdsByCategoryId.get(category.id) ?? []) { + for (const variantId of variantIdsByProductId.get(productId) ?? []) { + allowed.add(variantId); + } + } + } + + return allowed; + } + + private getCodeIssue( + entity: DiscountCode, + cartSubtotal: number, + cartVariantIds: string[], + accepted: DiscountCode[], + allowedVariantIds: Set | null + ): CodeIssue | null { + const { isActive, validFrom, validUntil, maxRedemptions, redemptionCount, minOrderAmount } = entity; + + if (!isActive) { + return { message: 'This discount code is not active' }; + } + + if (validFrom && dayjs().isBefore(validFrom)) { + return { message: 'This discount code is not valid yet' }; + } + + if (validUntil && dayjs().isAfter(validUntil)) { + return { message: 'This discount code has expired' }; + } + + const redemptionLimitMessage = getRedemptionLimitIssue(redemptionCount, maxRedemptions); + + if (redemptionLimitMessage) { + return { message: redemptionLimitMessage }; + } + + const variantEligibilityIssue = this.getVariantEligibilityIssue(cartVariantIds, allowedVariantIds); + + if (variantEligibilityIssue) { + return variantEligibilityIssue; + } + + if (minOrderAmount !== null && cartSubtotal < minOrderAmount) { + const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings']; + + return { + message: `Minimum order amount for this code is ${minOrderAmount} ${shopFiatCurrency}` + }; + } + + if (entity.isExclusive && accepted.length > 0) { + return { message: 'This code cannot be combined with other discounts' }; + } + + if (accepted.some(e => e.isExclusive)) { + return { message: 'This code cannot be combined with other discounts' }; + } + + return null; + } + + private getVariantEligibilityIssue( + cartVariantIds: string[], + allowedVariantIds: Set | null + ): CodeIssue | null { + if (allowedVariantIds === null) { + return null; + } + + const ineligibleVariantIds = cartVariantIds.filter(id => !allowedVariantIds.has(id)); + + if (ineligibleVariantIds.length === 0) { + return null; + } + + return { + message: 'This code only applies when your cart contains only selected products', + ineligibleVariantIds + }; + } + + private sortForDiscountApplication(codes: DiscountCode[]): DiscountCode[] { + return [...codes].sort((a, b) => { + if (a.type !== b.type) { + return a.type === DiscountType.Percent ? -1 : 1; + } + + return b.value - a.value; + }); + } + + private computeCartDiscounts(cartSubtotal: number, entities: DiscountCode[]): ComputedCartDiscount[] { + let remaining = new Decimal(cartSubtotal); + + const computed: ComputedCartDiscount[] = []; + + for (const entity of entities) { + const computedAmount = this.computeDiscountOnRemaining(remaining, entity); + + if (computedAmount.lte(0)) { + continue; + } + + computed.push({ + code: entity.code, + amount: computedAmount.toDecimalPlaces(2).toNumber() + }); + + remaining = remaining.minus(computedAmount); + } + + return computed; + } + + private computeDiscountOnRemaining(remaining: Decimal, entity: DiscountCode): Decimal { + if (remaining.lte(0)) { + return new Decimal(0); + } + + if (entity.type === DiscountType.Percent) { + const discount = remaining.mul(entity.value).div(100); + + return Decimal.min(discount, remaining); + } + + return Decimal.min(new Decimal(entity.value), remaining); + } +} diff --git a/backend/src/modules/storefrontCart/services/StorefrontCartService.spec.ts b/backend/src/modules/storefrontCart/services/StorefrontCartService.spec.ts new file mode 100644 index 0000000..cf5ca2c --- /dev/null +++ b/backend/src/modules/storefrontCart/services/StorefrontCartService.spec.ts @@ -0,0 +1,253 @@ +import { BadRequestException } from '@nestjs/common'; +import { DeliveryMode } from '../../product/types/DeliveryMode'; +import type { StorefrontProductsService } from '../../storefrontProduct/services/StorefrontProductsService'; +import type { XmrRateService } from '../../xmrRate/services/XmrRateService'; +import type { CookieCartLineExtended } from '../types/CookieCartLineExtended'; +import type { StorefrontDiscountService } from './StorefrontDiscountService'; +import { StorefrontCartService } from './StorefrontCartService'; + +const variantId = '11111111-1111-4111-8111-111111111111'; +const otherVariantId = '22222222-2222-4222-8222-222222222222'; + +const buildVariant = (overrides: Partial = {}) => ({ + id: variantId, + productId: 'product-1', + productTitle: 'Product', + title: 'Variant', + price: 10, + deliveryMode: DeliveryMode.Auto, + stockAvailable: 5, + stockForSession: 5, + thumbnailUrl: null, + images: [], + ...overrides +}); + +describe('StorefrontCartService', () => { + let service: StorefrontCartService; + let productsService: { + getStorefrontVariantsByIds: jest.Mock; + getStorefrontVariant: jest.Mock; + }; + let xmrRateService: { + getLiveFiatPerXmr: jest.Mock; + }; + let discountService: { + getDiscountStateForCart: jest.Mock; + applyDiscountCode: jest.Mock; + }; + + beforeEach(() => { + productsService = { + getStorefrontVariantsByIds: jest.fn().mockResolvedValue(new Map([[variantId, buildVariant()]])), + getStorefrontVariant: jest.fn().mockResolvedValue(buildVariant()) + }; + + xmrRateService = { + getLiveFiatPerXmr: jest.fn().mockReturnValue(150) + }; + + discountService = { + getDiscountStateForCart: jest.fn().mockResolvedValue({ + discounts: [], + cartDiscountTotal: 0, + cartTotalPrice: 10 + }), + applyDiscountCode: jest.fn().mockResolvedValue(['SAVE10']) + }; + + service = new StorefrontCartService( + productsService as unknown as StorefrontProductsService, + xmrRateService as unknown as XmrRateService, + discountService as unknown as StorefrontDiscountService + ); + }); + + describe('getCartExtended', () => { + it('returns an empty list for an empty cart', async () => { + await expect(service.getCartExtended([])).resolves.toEqual([]); + expect(productsService.getStorefrontVariantsByIds).not.toHaveBeenCalled(); + }); + + it('omits lines for variants that no longer exist', async () => { + productsService.getStorefrontVariantsByIds.mockResolvedValue(new Map()); + + const result = await service.getCartExtended([{ variantId, qty: 1 }]); + + expect(result).toEqual([]); + }); + + it('computes line subtotals from variant price and quantity', async () => { + productsService.getStorefrontVariantsByIds.mockResolvedValue( + new Map([[variantId, buildVariant({ price: 12.5 })]]) + ); + + const result = await service.getCartExtended([{ variantId, qty: 2 }]); + + expect(result[0].lineSubtotal).toBe(25); + }); + }); + + describe('getCartSummary', () => { + it('returns a zeroed summary for an empty cart', async () => { + const summary = await service.getCartSummary([], []); + + expect(summary).toEqual({ + cartExtended: [], + cartSubtotal: 0, + discounts: [], + cartDiscountTotal: 0, + cartTotalPrice: 0, + cartTotalXmr: null, + fiatPerXmr: null, + hasManualLines: false, + hasAutoLines: false, + cartTotalIssueMessage: null, + hasIssues: false + }); + }); + + it('converts the discounted total to XMR when a live rate is available', async () => { + const summary = await service.getCartSummary([{ variantId, qty: 1 }], []); + + expect(summary.cartSubtotal).toBe(10); + expect(summary.cartTotalPrice).toBe(10); + expect(summary.fiatPerXmr).toBe(150); + expect(summary.cartTotalXmr).toBe('0.06666667'); + expect(summary.hasAutoLines).toBe(true); + }); + + it('leaves cartTotalXmr null when no live rate is available', async () => { + xmrRateService.getLiveFiatPerXmr.mockReturnValue(null); + + const summary = await service.getCartSummary([{ variantId, qty: 1 }], []); + + expect(summary.cartTotalXmr).toBeNull(); + expect(summary.fiatPerXmr).toBeNull(); + }); + + it('flags manual and auto delivery lines separately', async () => { + productsService.getStorefrontVariantsByIds.mockResolvedValue( + new Map([ + [variantId, buildVariant({ deliveryMode: DeliveryMode.Manual })], + [otherVariantId, buildVariant({ id: otherVariantId, deliveryMode: DeliveryMode.Auto })] + ]) + ); + discountService.getDiscountStateForCart.mockResolvedValue({ + discounts: [], + cartDiscountTotal: 0, + cartTotalPrice: 20 + }); + + const summary = await service.getCartSummary( + [ + { variantId, qty: 1 }, + { variantId: otherVariantId, qty: 1 } + ], + [] + ); + + expect(summary.hasManualLines).toBe(true); + expect(summary.hasAutoLines).toBe(true); + }); + + it('sets hasIssues when discounts, stock, or zero-total issues are present', async () => { + discountService.getDiscountStateForCart.mockResolvedValue({ + discounts: [{ code: 'BAD', amount: null, issueMessage: 'Invalid discount code' }], + cartDiscountTotal: 0, + cartTotalPrice: 10 + }); + productsService.getStorefrontVariantsByIds.mockResolvedValue( + new Map([[variantId, buildVariant({ stockAvailable: 0 })]]) + ); + + const withDiscountIssue = await service.getCartSummary([{ variantId, qty: 1 }], ['bad']); + productsService.getStorefrontVariantsByIds.mockResolvedValue( + new Map([[variantId, buildVariant({ stockAvailable: 1 })]]) + ); + const withStockIssue = await service.getCartSummary([{ variantId, qty: 2 }], []); + discountService.getDiscountStateForCart.mockResolvedValue({ + discounts: [], + cartDiscountTotal: 10, + cartTotalPrice: 0 + }); + const withZeroTotal = await service.getCartSummary([{ variantId, qty: 1 }], ['free']); + + expect(withDiscountIssue.hasIssues).toBe(true); + expect(withStockIssue.cartExtended[0].stockIssueMessage).toBe('Insufficient stock, 1 left'); + expect(withStockIssue.hasIssues).toBe(true); + expect(withZeroTotal.cartTotalIssueMessage).toBe('Cart total cannot be 0'); + expect(withZeroTotal.hasIssues).toBe(true); + }); + }); + + describe('applyDiscountCode', () => { + it('delegates to the discount service with the extended cart', async () => { + const nextCodes = await service.applyDiscountCode([{ variantId, qty: 1 }], [], 'save10'); + + expect(discountService.applyDiscountCode).toHaveBeenCalledWith( + [], + 'save10', + expect.arrayContaining([expect.objectContaining({ id: variantId, qty: 1 })]) + ); + expect(nextCodes).toEqual(['SAVE10']); + }); + }); + + describe('cart mutations', () => { + it('adds items to the cart and merges duplicate variant rows', async () => { + const mutation = await service.addToCart([{ variantId, qty: 2 }], variantId, 1); + + expect(mutation).toEqual([{ variantId, qty: 3 }]); + }); + + it('rejects adds that exceed stock reserved for this checkout session', async () => { + productsService.getStorefrontVariant.mockResolvedValue( + buildVariant({ stockAvailable: 10, stockForSession: 2 }) + ); + + await expect(service.addToCart([], variantId, 3)).rejects.toThrow( + new BadRequestException('Insufficient stock') + ); + }); + + it('rejects add and update operations that exceed available stock', async () => { + await expect(service.addToCart([], variantId, 6)).rejects.toThrow( + new BadRequestException('Insufficient stock') + ); + + await expect(service.updateCartQty([{ variantId, qty: 1 }], variantId, 6)).rejects.toThrow( + new BadRequestException('Insufficient stock') + ); + }); + + it('removes a line when quantity is set to zero', async () => { + const mutation = await service.updateCartQty( + [ + { variantId, qty: 1 }, + { variantId: otherVariantId, qty: 1 } + ], + variantId, + 0 + ); + + expect(mutation).toEqual([{ variantId: otherVariantId, qty: 1 }]); + }); + + it('rejects updates for variants that are not already in the cart', async () => { + await expect(service.updateCartQty([], variantId, 1)).rejects.toThrow( + new BadRequestException('Product not in cart') + ); + }); + + it('rejects invalid cart rows during normalization', async () => { + await expect(service.addToCart([{ variantId: 'not-a-uuid', qty: 1 }], variantId, 1)).rejects.toThrow( + new BadRequestException('Invalid cart') + ); + }); + + it('returns null when the cart becomes empty', async () => { + expect(service.removeFromCart([{ variantId, qty: 1 }], variantId)).toBeNull(); + }); + }); +}); diff --git a/backend/src/modules/storefrontCart/services/StorefrontCartService.ts b/backend/src/modules/storefrontCart/services/StorefrontCartService.ts new file mode 100644 index 0000000..853dba3 --- /dev/null +++ b/backend/src/modules/storefrontCart/services/StorefrontCartService.ts @@ -0,0 +1,192 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; +import Decimal from 'decimal.js'; +import type { CartCookieMutation } from '../types/CartCookieMutation'; +import type { CookieCart } from '../../storefrontCore/types/cart/CookieCart'; +import { getQtyByVariantIdFromCart } from '../../../utils/cart/getQtyByVariantIdFromCart'; +import { DeliveryMode } from '../../product/types/DeliveryMode'; +import { StorefrontProductsService } from '../../storefrontProduct/services/StorefrontProductsService'; +import { XmrRateService } from '../../xmrRate/services/XmrRateService'; +import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr'; +import { CookieCartLineDto } from '../dto/CookieCartLineDto'; +import type { CookieCartExtended } from '../types/CookieCartExtended'; +import type { CookieCartLineExtended } from '../types/CookieCartLineExtended'; +import type { CookieCartSummary } from '../types/CookieCartSummary'; +import { sumByKey } from '../../../utils/sumByKey'; +import { getStockIssue } from '../utils/getStockIssue'; +import { getZeroCartTotalIssue } from '../utils/getZeroCartTotalIssue'; +import { StorefrontDiscountService } from './StorefrontDiscountService'; + +@Injectable() +export class StorefrontCartService { + constructor( + private readonly productsService: StorefrontProductsService, + private readonly xmrRateService: XmrRateService, + private readonly discountService: StorefrontDiscountService + ) {} + + async getCartExtended(cart: CookieCart): Promise { + if (cart.length === 0) { + return []; + } + + const qtyByVariant = getQtyByVariantIdFromCart(cart); + + const storefrontVariantsMap = await this.productsService.getStorefrontVariantsByIds( + cart.map(l => l.variantId), + qtyByVariant + ); + + return cart + .map(l => { + const variant = storefrontVariantsMap.get(l.variantId); + + if (!variant) { + return null; + } + + return { + ...variant, + qty: l.qty, + lineSubtotal: new Decimal(variant.price).mul(l.qty).toDecimalPlaces(2).toNumber(), + stockIssueMessage: getStockIssue(l.qty, variant.stockAvailable) + }; + }) + .filter((l): l is CookieCartLineExtended => l !== null); + } + + async getCartSummary(cart: CookieCart, discountCodes: string[]): Promise { + const cartExtended = await this.getCartExtended(cart); + + if (cartExtended.length === 0) { + return { + cartExtended, + cartSubtotal: 0, + discounts: [], + cartDiscountTotal: 0, + cartTotalPrice: 0, + cartTotalXmr: null, + fiatPerXmr: null, + hasManualLines: false, + hasAutoLines: false, + cartTotalIssueMessage: null, + hasIssues: false + }; + } + + const cartSubtotal = sumByKey(cartExtended, 'lineSubtotal'); + const discountState = await this.discountService.getDiscountStateForCart(discountCodes, cartExtended); + const fiatPerXmr = this.xmrRateService.getLiveFiatPerXmr(); + const cartTotalXmr = fiatPerXmr !== null ? convertFiatToXmr(discountState.cartTotalPrice, fiatPerXmr) : null; + const hasManualLines = cartExtended.some(line => line.deliveryMode === DeliveryMode.Manual); + const hasAutoLines = cartExtended.some(line => line.deliveryMode === DeliveryMode.Auto); + const cartTotalIssueMessage = getZeroCartTotalIssue(discountState.cartTotalPrice, cartExtended.length > 0); + const hasIssues = + discountState.discounts.some(d => d.issueMessage) || + cartExtended.some(line => line.stockIssueMessage) || + cartTotalIssueMessage !== null; + + return { + cartExtended, + cartSubtotal, + ...discountState, + cartTotalXmr, + fiatPerXmr, + hasManualLines, + hasAutoLines, + cartTotalIssueMessage, + hasIssues + }; + } + + async applyDiscountCode(cart: CookieCart, discountCodes: string[], code: string): Promise { + const cartExtended = await this.getCartExtended(cart); + const nextDiscountCodes = await this.discountService.applyDiscountCode(discountCodes, code, cartExtended); + + return nextDiscountCodes; + } + + async addToCart(cart: CookieCart, variantId: string, qty: number): Promise { + const qtyByVariant = getQtyByVariantIdFromCart(cart); + const variant = await this.productsService.getStorefrontVariant(variantId, qtyByVariant); + + if (qty > variant.stockForSession) { + throw new BadRequestException('Insufficient stock'); + } + + const cartMap = new Map(cart.map(l => [l.variantId, l.qty])); + const nextQty = (cartMap.get(variantId) ?? 0) + qty; + + if (nextQty > variant.stockAvailable) { + throw new BadRequestException('Insufficient stock'); + } + + cartMap.set(variantId, nextQty); + + const nextCart: CookieCart = [...cartMap.entries()].map(([vid, q]) => ({ variantId: vid, qty: q })); + const validCart = this.validateAndNormalizeCart(nextCart); + + return this.toCartCookieMutation(validCart); + } + + async updateCartQty(cart: CookieCart, variantId: string, qty: number): Promise { + if (qty === 0) { + return this.removeFromCart(cart, variantId); + } + + const qtyByVariant = getQtyByVariantIdFromCart(cart); + const variant = await this.productsService.getStorefrontVariant(variantId, qtyByVariant); + + if (qty > variant.stockAvailable) { + throw new BadRequestException('Insufficient stock'); + } + + const hasLine = cart.some(l => l.variantId === variantId); + + if (!hasLine) { + throw new BadRequestException('Product not in cart'); + } + + const nextCart: CookieCart = cart.map(l => (l.variantId === variantId ? { variantId, qty } : l)); + const validCart = this.validateAndNormalizeCart(nextCart); + + return this.toCartCookieMutation(validCart); + } + + removeFromCart(cart: CookieCart, variantId: string): CartCookieMutation { + const nextCart = cart.filter(l => l.variantId !== variantId); + const validCart = this.validateAndNormalizeCart(nextCart); + + return this.toCartCookieMutation(validCart); + } + + private toCartCookieMutation(validCart: CookieCart): CartCookieMutation { + if (validCart.length === 0) { + return null; + } + + return validCart; + } + + private validateAndNormalizeCart(cart: CookieCart): CookieCart { + const qtyByVariant = new Map(); + + for (const row of cart) { + if (!row || typeof row !== 'object' || Array.isArray(row)) { + throw new BadRequestException('Invalid cart'); + } + + const dto = plainToInstance(CookieCartLineDto, row); + const rowErrors = validateSync(dto); + + if (rowErrors.length > 0) { + throw new BadRequestException('Invalid cart'); + } + + qtyByVariant.set(dto.variantId, (qtyByVariant.get(dto.variantId) ?? 0) + dto.qty); + } + + return [...qtyByVariant.entries()].map(([variantId, qty]) => ({ variantId, qty })); + } +} diff --git a/backend/src/modules/storefrontCart/services/StorefrontDiscountService.spec.ts b/backend/src/modules/storefrontCart/services/StorefrontDiscountService.spec.ts new file mode 100644 index 0000000..0e57363 --- /dev/null +++ b/backend/src/modules/storefrontCart/services/StorefrontDiscountService.spec.ts @@ -0,0 +1,119 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DiscountCodesService } from '../../discountCode/services/DiscountCodesService'; +import { DeliveryMode } from '../../product/types/DeliveryMode'; +import type { CookieCartLineExtended } from '../types/CookieCartLineExtended'; +import type { StorefrontCartDiscountResolver } from './StorefrontCartDiscountResolver'; +import { StorefrontDiscountService } from './StorefrontDiscountService'; + +const buildCartLine = (): CookieCartLineExtended => + ({ + id: 'variant-1', + productId: 'product-1', + productTitle: 'Product', + title: 'Variant', + price: 50, + deliveryMode: DeliveryMode.Auto, + stockAvailable: 10, + stockForSession: 10, + thumbnailUrl: null, + images: [], + qty: 1, + lineSubtotal: 50, + stockIssueMessage: null + }) as CookieCartLineExtended; + +describe('StorefrontDiscountService', () => { + let service: StorefrontDiscountService; + let discountCodesService: { + normalizeCode: jest.Mock; + }; + let cartDiscountResolver: { + resolve: jest.Mock; + }; + + beforeEach(() => { + discountCodesService = { + normalizeCode: jest.fn((code: string) => code.trim().toUpperCase()) + }; + + cartDiscountResolver = { + resolve: jest.fn().mockResolvedValue({ + cartSubtotal: 50, + cartTotalPrice: 45, + discounts: [{ code: 'SAVE10', amountFiat: 5, issueMessage: null }] + }) + }; + + service = new StorefrontDiscountService( + discountCodesService as unknown as DiscountCodesService, + cartDiscountResolver as unknown as StorefrontCartDiscountResolver + ); + }); + + it('delegates discount state resolution to the cart discount resolver', async () => { + const cartExtended = [buildCartLine()]; + const discountState = await service.getDiscountStateForCart(['SAVE10'], cartExtended); + + expect(cartDiscountResolver.resolve).toHaveBeenCalledWith(cartExtended, ['SAVE10']); + expect(discountState).toEqual( + expect.objectContaining({ + cartTotalPrice: 45, + discounts: [{ code: 'SAVE10', amountFiat: 5, issueMessage: null }] + }) + ); + }); + + it('rejects applying a code to an empty cart', async () => { + await expect(service.applyDiscountCode([], ' save10 ', [])).rejects.toThrow( + new BadRequestException('Add items to your cart before applying a discount code') + ); + + expect(cartDiscountResolver.resolve).not.toHaveBeenCalled(); + }); + + it('rejects applying a code that is already on the cart', async () => { + await expect(service.applyDiscountCode(['SAVE10'], 'save10', [buildCartLine()])).rejects.toThrow( + new BadRequestException('Code already applied') + ); + + expect(cartDiscountResolver.resolve).not.toHaveBeenCalled(); + }); + + it('rejects applying a code when the resolver reports an issue', async () => { + cartDiscountResolver.resolve.mockResolvedValue({ + cartSubtotal: 50, + cartTotalPrice: 50, + discounts: [{ code: 'EXPIRED', amountFiat: null, issueMessage: 'Discount code has expired' }] + }); + + await expect(service.applyDiscountCode([], ' expired ', [buildCartLine()])).rejects.toThrow( + new BadRequestException('Discount code has expired') + ); + + expect(cartDiscountResolver.resolve).toHaveBeenCalledWith([buildCartLine()], ['EXPIRED']); + }); + + it('returns normalized codes when the resolver accepts the new code', async () => { + cartDiscountResolver.resolve.mockResolvedValue({ + cartSubtotal: 50, + cartTotalPrice: 45, + discounts: [ + { code: 'SAVE10', amountFiat: 5, issueMessage: null }, + { code: 'EXTRA5', amountFiat: 0, issueMessage: null } + ] + }); + + await expect(service.applyDiscountCode(['SAVE10'], ' extra5 ', [buildCartLine()])).resolves.toEqual([ + 'SAVE10', + 'EXTRA5' + ]); + }); + + it('removes a normalized discount code from the cookie list', () => { + expect(service.removeDiscountCode(['SAVE10', 'EXTRA5'], ' save10 ')).toEqual(['EXTRA5']); + }); + + it('returns null when removing the last discount code', () => { + expect(service.removeDiscountCode(['SAVE10'], 'save10')).toBeNull(); + }); +}); diff --git a/backend/src/modules/storefrontCart/services/StorefrontDiscountService.ts b/backend/src/modules/storefrontCart/services/StorefrontDiscountService.ts new file mode 100644 index 0000000..aee8332 --- /dev/null +++ b/backend/src/modules/storefrontCart/services/StorefrontDiscountService.ts @@ -0,0 +1,54 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { DiscountCodesService } from '../../discountCode/services/DiscountCodesService'; +import type { CookieCartExtended } from '../types/CookieCartExtended'; +import type { DiscountCookieMutation } from '../types/DiscountCookieMutation'; +import type { StorefrontCartDiscountState } from '../types/StorefrontCartDiscountState'; +import { StorefrontCartDiscountResolver } from './StorefrontCartDiscountResolver'; + +@Injectable() +export class StorefrontDiscountService { + constructor( + private readonly discountCodesService: DiscountCodesService, + private readonly cartDiscountResolver: StorefrontCartDiscountResolver + ) {} + + async getDiscountStateForCart( + discountCodes: string[], + cartExtended: CookieCartExtended + ): Promise { + return this.cartDiscountResolver.resolve(cartExtended, discountCodes); + } + + async applyDiscountCode( + discountCodes: string[], + code: string, + cartExtended: CookieCartExtended + ): Promise { + if (cartExtended.length === 0) { + throw new BadRequestException('Add items to your cart before applying a discount code'); + } + + const normalized = this.discountCodesService.normalizeCode(code); + + if (discountCodes.includes(normalized)) { + throw new BadRequestException('Code already applied'); + } + + const simulatedCodes = [...discountCodes, normalized]; + const resolution = await this.cartDiscountResolver.resolve(cartExtended, simulatedCodes); + const issueMessage = resolution.discounts.find(d => d.code === normalized)?.issueMessage; + + if (issueMessage) { + throw new BadRequestException(issueMessage); + } + + return simulatedCodes; + } + + removeDiscountCode(discountCodes: string[], code: string): DiscountCookieMutation { + const normalized = this.discountCodesService.normalizeCode(code); + const nextCodes = discountCodes.filter(c => c !== normalized); + + return nextCodes.length === 0 ? null : nextCodes; + } +} diff --git a/backend/src/modules/storefrontCart/types/CartCookieMutation.ts b/backend/src/modules/storefrontCart/types/CartCookieMutation.ts new file mode 100644 index 0000000..97768c0 --- /dev/null +++ b/backend/src/modules/storefrontCart/types/CartCookieMutation.ts @@ -0,0 +1,3 @@ +import type { CookieCart } from '../../storefrontCore/types/cart/CookieCart'; + +export type CartCookieMutation = CookieCart | null; diff --git a/backend/src/modules/storefrontCart/types/CartDiscountResolved.ts b/backend/src/modules/storefrontCart/types/CartDiscountResolved.ts new file mode 100644 index 0000000..9dfca2b --- /dev/null +++ b/backend/src/modules/storefrontCart/types/CartDiscountResolved.ts @@ -0,0 +1,6 @@ +export type CartDiscountResolved = { + code: string; + amount: number | null; + issueMessage: string | null; + ineligibleVariantIds?: string[]; +}; diff --git a/backend/src/modules/storefrontCart/types/CodeIssue.ts b/backend/src/modules/storefrontCart/types/CodeIssue.ts new file mode 100644 index 0000000..d521f2c --- /dev/null +++ b/backend/src/modules/storefrontCart/types/CodeIssue.ts @@ -0,0 +1,4 @@ +export type CodeIssue = { + message: string; + ineligibleVariantIds?: string[]; +}; diff --git a/backend/src/modules/storefrontCart/types/ComputedCartDiscount.ts b/backend/src/modules/storefrontCart/types/ComputedCartDiscount.ts new file mode 100644 index 0000000..89ed27a --- /dev/null +++ b/backend/src/modules/storefrontCart/types/ComputedCartDiscount.ts @@ -0,0 +1,4 @@ +export type ComputedCartDiscount = { + code: string; + amount: number; +}; diff --git a/backend/src/modules/storefrontCart/types/CookieCartExtended.ts b/backend/src/modules/storefrontCart/types/CookieCartExtended.ts new file mode 100644 index 0000000..747058e --- /dev/null +++ b/backend/src/modules/storefrontCart/types/CookieCartExtended.ts @@ -0,0 +1,3 @@ +import type { CookieCartLineExtended } from './CookieCartLineExtended'; + +export type CookieCartExtended = CookieCartLineExtended[]; diff --git a/backend/src/modules/storefrontCart/types/CookieCartLineExtended.ts b/backend/src/modules/storefrontCart/types/CookieCartLineExtended.ts new file mode 100644 index 0000000..569b069 --- /dev/null +++ b/backend/src/modules/storefrontCart/types/CookieCartLineExtended.ts @@ -0,0 +1,7 @@ +import type { StorefrontVariant } from '../../storefrontProduct/types/StorefrontVariant'; + +export type CookieCartLineExtended = StorefrontVariant & { + qty: number; + lineSubtotal: number; + stockIssueMessage: string | null; +}; diff --git a/backend/src/modules/storefrontCart/types/CookieCartSummary.ts b/backend/src/modules/storefrontCart/types/CookieCartSummary.ts new file mode 100644 index 0000000..f162516 --- /dev/null +++ b/backend/src/modules/storefrontCart/types/CookieCartSummary.ts @@ -0,0 +1,13 @@ +import type { StorefrontCartDiscountState } from './StorefrontCartDiscountState'; +import type { CookieCartExtended } from './CookieCartExtended'; + +export type CookieCartSummary = { + cartExtended: CookieCartExtended; + cartSubtotal: number; + cartTotalXmr: string | null; + fiatPerXmr: number | null; + hasManualLines: boolean; + hasAutoLines: boolean; + cartTotalIssueMessage: string | null; + hasIssues: boolean; +} & StorefrontCartDiscountState; diff --git a/backend/src/modules/storefrontCart/types/DiscountCookieMutation.ts b/backend/src/modules/storefrontCart/types/DiscountCookieMutation.ts new file mode 100644 index 0000000..169fa31 --- /dev/null +++ b/backend/src/modules/storefrontCart/types/DiscountCookieMutation.ts @@ -0,0 +1 @@ +export type DiscountCookieMutation = string[] | null; diff --git a/backend/src/modules/storefrontCart/types/StorefrontCartDiscountState.ts b/backend/src/modules/storefrontCart/types/StorefrontCartDiscountState.ts new file mode 100644 index 0000000..4182062 --- /dev/null +++ b/backend/src/modules/storefrontCart/types/StorefrontCartDiscountState.ts @@ -0,0 +1,7 @@ +import type { CartDiscountResolved } from './CartDiscountResolved'; + +export type StorefrontCartDiscountState = { + discounts: CartDiscountResolved[]; + cartDiscountTotal: number; + cartTotalPrice: number; +}; diff --git a/backend/src/modules/storefrontCart/utils/getRedemptionLimitIssue.spec.ts b/backend/src/modules/storefrontCart/utils/getRedemptionLimitIssue.spec.ts new file mode 100644 index 0000000..55dbfd4 --- /dev/null +++ b/backend/src/modules/storefrontCart/utils/getRedemptionLimitIssue.spec.ts @@ -0,0 +1,19 @@ +import { getRedemptionLimitIssue } from './getRedemptionLimitIssue'; + +describe('getRedemptionLimitIssue', () => { + it('returns null when there is no redemption limit', () => { + expect(getRedemptionLimitIssue(10, null)).toBeNull(); + }); + + it('returns null when redemptions remain', () => { + expect(getRedemptionLimitIssue(4, 5)).toBeNull(); + }); + + it('returns a message when the redemption limit is reached', () => { + expect(getRedemptionLimitIssue(5, 5)).toBe('This discount code has reached its usage limit'); + }); + + it('returns a message when redemptions exceed the configured limit', () => { + expect(getRedemptionLimitIssue(6, 5)).toBe('This discount code has reached its usage limit'); + }); +}); diff --git a/backend/src/modules/storefrontCart/utils/getRedemptionLimitIssue.ts b/backend/src/modules/storefrontCart/utils/getRedemptionLimitIssue.ts new file mode 100644 index 0000000..2c8533b --- /dev/null +++ b/backend/src/modules/storefrontCart/utils/getRedemptionLimitIssue.ts @@ -0,0 +1,7 @@ +export const getRedemptionLimitIssue = (redemptionCount: number, maxRedemptions: number | null): string | null => { + if (maxRedemptions === null || redemptionCount < maxRedemptions) { + return null; + } + + return 'This discount code has reached its usage limit'; +}; diff --git a/backend/src/modules/storefrontCart/utils/getStockIssue.spec.ts b/backend/src/modules/storefrontCart/utils/getStockIssue.spec.ts new file mode 100644 index 0000000..5513f7d --- /dev/null +++ b/backend/src/modules/storefrontCart/utils/getStockIssue.spec.ts @@ -0,0 +1,19 @@ +import { getStockIssue } from './getStockIssue'; + +describe('getStockIssue', () => { + it('returns null when requested quantity is within stock', () => { + expect(getStockIssue(2, 5)).toBeNull(); + }); + + it('returns null when requested quantity equals available stock', () => { + expect(getStockIssue(5, 5)).toBeNull(); + }); + + it('returns a stock issue message when quantity exceeds availability', () => { + expect(getStockIssue(3, 1)).toBe('Insufficient stock, 1 left'); + }); + + it('returns null when stock is unavailable but nothing was requested', () => { + expect(getStockIssue(0, 0)).toBeNull(); + }); +}); diff --git a/backend/src/modules/storefrontCart/utils/getStockIssue.ts b/backend/src/modules/storefrontCart/utils/getStockIssue.ts new file mode 100644 index 0000000..0d15390 --- /dev/null +++ b/backend/src/modules/storefrontCart/utils/getStockIssue.ts @@ -0,0 +1,7 @@ +export const getStockIssue = (qty: number, stockAvailable: number): string | null => { + if (qty <= stockAvailable) { + return null; + } + + return `Insufficient stock, ${stockAvailable} left`; +}; diff --git a/backend/src/modules/storefrontCart/utils/getZeroCartTotalIssue.spec.ts b/backend/src/modules/storefrontCart/utils/getZeroCartTotalIssue.spec.ts new file mode 100644 index 0000000..6bb606e --- /dev/null +++ b/backend/src/modules/storefrontCart/utils/getZeroCartTotalIssue.spec.ts @@ -0,0 +1,15 @@ +import { getZeroCartTotalIssue } from './getZeroCartTotalIssue'; + +describe('getZeroCartTotalIssue', () => { + it('returns null when the cart has a positive total', () => { + expect(getZeroCartTotalIssue(25, true)).toBeNull(); + }); + + it('returns null when the cart has no lines', () => { + expect(getZeroCartTotalIssue(0, false)).toBeNull(); + }); + + it('returns an issue when the cart has lines but a zero total', () => { + expect(getZeroCartTotalIssue(0, true)).toBe('Cart total cannot be 0'); + }); +}); diff --git a/backend/src/modules/storefrontCart/utils/getZeroCartTotalIssue.ts b/backend/src/modules/storefrontCart/utils/getZeroCartTotalIssue.ts new file mode 100644 index 0000000..6f9c13c --- /dev/null +++ b/backend/src/modules/storefrontCart/utils/getZeroCartTotalIssue.ts @@ -0,0 +1,7 @@ +export const getZeroCartTotalIssue = (cartTotalPrice: number, hasLines: boolean): string | null => { + if (!hasLines || cartTotalPrice > 0) { + return null; + } + + return 'Cart total cannot be 0'; +}; diff --git a/backend/src/modules/storefrontCheckout/StorefrontCheckoutModule.ts b/backend/src/modules/storefrontCheckout/StorefrontCheckoutModule.ts new file mode 100644 index 0000000..fa06b51 --- /dev/null +++ b/backend/src/modules/storefrontCheckout/StorefrontCheckoutModule.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { OrderModule } from '../order/OrderModule'; +import { PaymentModule } from '../payment/PaymentModule'; +import { StorefrontCartModule } from '../storefrontCart/StorefrontCartModule'; +import { StorefrontCoreModule } from '../storefrontCore/StorefrontCoreModule'; +import { StorefrontCheckoutController } from './controllers/StorefrontCheckoutController'; +import { CheckoutSessionDiscount } from './entities/CheckoutSessionDiscount'; +import { CheckoutSessionLine } from './entities/CheckoutSessionLine'; +import { CheckoutSession } from './entities/CheckoutSession'; +import { CheckoutPaymentPollerService } from './services/CheckoutPaymentPollerService'; +import { CheckoutSessionService } from './services/CheckoutSessionService'; +import { StorefrontCheckoutViewService } from './services/StorefrontCheckoutViewService'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([CheckoutSession, CheckoutSessionLine, CheckoutSessionDiscount]), + StorefrontCoreModule, + StorefrontCartModule, + PaymentModule, + OrderModule + ], + controllers: [StorefrontCheckoutController], + providers: [CheckoutSessionService, StorefrontCheckoutViewService, CheckoutPaymentPollerService], + exports: [TypeOrmModule.forFeature([CheckoutSession])] +}) +export class StorefrontCheckoutModule {} diff --git a/backend/src/modules/storefrontCheckout/controllers/StorefrontCheckoutController.ts b/backend/src/modules/storefrontCheckout/controllers/StorefrontCheckoutController.ts new file mode 100644 index 0000000..0056244 --- /dev/null +++ b/backend/src/modules/storefrontCheckout/controllers/StorefrontCheckoutController.ts @@ -0,0 +1,180 @@ +import { Controller, Get, HttpStatus, Post, Req, Res, UseFilters, Body, BadRequestException } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import { ConfigService } from '@nestjs/config'; +import type { Request, Response } from 'express'; +import type { Config } from '../../../types/Config'; +import { throttleProfiles } from '../../../config/throttleProfiles'; +import { deriveCheckoutSessionState } from '../../../utils/checkout/deriveCheckoutSessionState'; +import { OrderService } from '../../order/services/OrderService'; +import { StorefrontExceptionFilter } from '../../storefrontCore/filters/StorefrontExceptionFilter'; +import { StorefrontCartCookieService } from '../../storefrontCore/services/StorefrontCartCookieService'; +import { StorefrontCheckoutSessionCookieService } from '../../storefrontCore/services/StorefrontCheckoutSessionCookieService'; +import { StorefrontDiscountCookieService } from '../../storefrontCore/services/StorefrontDiscountCookieService'; +import { StorefrontFeedbackCookieService } from '../../storefrontCore/services/StorefrontFeedbackCookieService'; +import { StorefrontCaptchaCookieService } from '../../storefrontCore/services/StorefrontCaptchaCookieService'; +import { StorefrontCaptchaService } from '../../storefrontCore/services/StorefrontCaptchaService'; +import { StorefrontOrderAuthCookieService } from '../../storefrontCore/services/StorefrontOrderAuthCookieService'; +import { StorefrontShopViewService } from '../../storefrontCore/services/StorefrontShopViewService'; +import { StorefrontCartService } from '../../storefrontCart/services/StorefrontCartService'; +import { CheckoutSessionService } from '../services/CheckoutSessionService'; +import { PayCheckoutDto } from '../dto/PayCheckoutDto'; +import { StorefrontCheckoutViewService } from '../services/StorefrontCheckoutViewService'; + +@Controller() +@UseFilters(StorefrontExceptionFilter) +export class StorefrontCheckoutController { + constructor( + private readonly cartService: StorefrontCartService, + private readonly checkoutSessionService: CheckoutSessionService, + private readonly checkoutViewService: StorefrontCheckoutViewService, + private readonly shopViewService: StorefrontShopViewService, + private readonly cartCookieService: StorefrontCartCookieService, + private readonly discountCookieService: StorefrontDiscountCookieService, + private readonly checkoutSessionCookieService: StorefrontCheckoutSessionCookieService, + private readonly feedbackCookieService: StorefrontFeedbackCookieService, + private readonly orderAuthCookieService: StorefrontOrderAuthCookieService, + private readonly orderService: OrderService, + private readonly configService: ConfigService, + private readonly captchaService: StorefrontCaptchaService, + private readonly captchaCookieService: StorefrontCaptchaCookieService + ) {} + + @Post('shop/checkout/pay') + @Throttle(throttleProfiles.checkoutPay) + async pay(@Req() req: Request, @Res() res: Response, @Body() { captcha }: PayCheckoutDto): Promise { + const sessionId = this.checkoutSessionCookieService.getSessionId(req, res); + + if (sessionId) { + res.redirect(HttpStatus.FOUND, '/shop/checkout'); + + return; + } + + const encryptedAnswer = this.captchaCookieService.getAnswer(req, res, { consume: true }); + + const isCaptchaValid = this.captchaService.verify(captcha, encryptedAnswer); + + if (!isCaptchaValid) { + throw new BadRequestException('Incorrect captcha. Try again.'); + } + + const cart = this.cartCookieService.getCart(req, res); + + const discountCodes = this.discountCookieService.getDiscountCodes(req, res); + + const summary = await this.cartService.getCartSummary(cart, discountCodes); + + const session = await this.checkoutSessionService.createFromCartSummary(summary); + + this.checkoutSessionCookieService.setSessionId(req, res, session.id); + + res.redirect(HttpStatus.FOUND, '/shop/checkout'); + } + + @Get('shop/checkout') + async checkoutPage(@Req() req: Request, @Res() res: Response) { + const sessionId = this.checkoutSessionCookieService.getSessionId(req, res); + + if (!sessionId) { + res.redirect(HttpStatus.FOUND, '/shop/cart'); + + return; + } + + const session = await this.checkoutSessionService.findById(sessionId); + + if (!session) { + this.checkoutSessionCookieService.clearSession(req, res); + + this.feedbackCookieService.setFeedback(req, res, { + type: 'error', + text: 'Checkout session no longer available.' + }); + + res.redirect(HttpStatus.FOUND, '/shop/cart'); + + return; + } + + const orderId = await this.orderService.findIdByCheckoutSessionId(sessionId); + + if (orderId) { + this.checkoutSessionCookieService.clearSession(req, res); + this.cartCookieService.clearCart(req, res); + this.discountCookieService.clearDiscount(req, res); + + this.orderAuthCookieService.grantAccess(req, res, orderId); + + this.feedbackCookieService.setFeedback(req, res, { + type: 'success', + text: 'Order placed successfully.' + }); + + res.redirect(HttpStatus.FOUND, `/shop/order/${orderId}`); + + return; + } + + const sessionState = deriveCheckoutSessionState(session); + + if (sessionState.isPastDue) { + this.checkoutSessionCookieService.clearSession(req, res); + + this.feedbackCookieService.setFeedback(req, res, { + type: 'error', + text: 'Checkout session expired.' + }); + + res.redirect(HttpStatus.FOUND, '/shop/cart'); + + return; + } + + if (sessionState.isCancelled) { + this.checkoutSessionCookieService.clearSession(req, res); + + this.feedbackCookieService.setFeedback(req, res, { + type: 'success', + text: 'Checkout cancelled.' + }); + + res.redirect(HttpStatus.FOUND, '/shop/cart'); + + return; + } + + const [shopLocals, checkoutView] = await Promise.all([ + this.shopViewService.buildShopRenderLocals(req, res, { + title: 'Checkout', + metaDescription: 'Complete your purchase at {shopName}.' + }), + this.checkoutViewService.toCheckoutView(session) + ]); + + const { checkoutStatusRefreshSec } = this.configService.get('order') as Config['order']; + + return res.render('checkout', { + checkout: checkoutView, + refreshSec: checkoutStatusRefreshSec, + ...shopLocals + }); + } + + @Post('shop/checkout/cancel') + async cancelCheckout(@Req() req: Request, @Res() res: Response): Promise { + const sessionId = this.checkoutSessionCookieService.getSessionId(req, res); + + if (sessionId) { + await this.checkoutSessionService.cancelSession(sessionId); + } + + this.checkoutSessionCookieService.clearSession(req, res); + + this.feedbackCookieService.setFeedback(req, res, { + type: 'success', + text: 'Checkout cancelled.' + }); + + res.redirect(HttpStatus.FOUND, '/shop/cart'); + } +} diff --git a/backend/src/modules/storefrontCheckout/dto/PayCheckoutDto.ts b/backend/src/modules/storefrontCheckout/dto/PayCheckoutDto.ts new file mode 100644 index 0000000..265ce0a --- /dev/null +++ b/backend/src/modules/storefrontCheckout/dto/PayCheckoutDto.ts @@ -0,0 +1,15 @@ +import { IsNotEmpty, IsString, Length } from 'class-validator'; +import { getAppConfig } from '../../../config'; + +const { + captcha: { length: captchaLength } +} = getAppConfig(); + +export class PayCheckoutDto { + @IsNotEmpty() + @IsString() + @Length(captchaLength, captchaLength, { + message: `Captcha should be ${captchaLength} characters long` + }) + captcha: string; +} diff --git a/backend/src/modules/storefrontCheckout/entities/CheckoutSession.ts b/backend/src/modules/storefrontCheckout/entities/CheckoutSession.ts new file mode 100644 index 0000000..0619a74 --- /dev/null +++ b/backend/src/modules/storefrontCheckout/entities/CheckoutSession.ts @@ -0,0 +1,42 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + OneToMany, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn +} from 'typeorm'; +import { Invoice } from '../../payment/entities/Invoice'; +import { Order } from '../../order/entities/Order'; +import { CheckoutSessionDiscount } from './CheckoutSessionDiscount'; +import { CheckoutSessionLine } from './CheckoutSessionLine'; + +@Entity('checkout_sessions') +export class CheckoutSession { + @PrimaryGeneratedColumn('uuid') + id: string; + + @OneToOne(() => Invoice, { cascade: true }) + @JoinColumn() + invoice: Invoice; + + @OneToOne(() => Order, order => order.checkoutSession) + order: Order | null; + + @OneToMany(() => CheckoutSessionLine, line => line.session, { cascade: true }) + lines: CheckoutSessionLine[]; + + @OneToMany(() => CheckoutSessionDiscount, discount => discount.session, { cascade: true }) + discounts: CheckoutSessionDiscount[]; + + @Column({ type: 'timestamptz', nullable: true }) + cancelledAt: Date | null; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/modules/storefrontCheckout/entities/CheckoutSessionDiscount.ts b/backend/src/modules/storefrontCheckout/entities/CheckoutSessionDiscount.ts new file mode 100644 index 0000000..5e86c1c --- /dev/null +++ b/backend/src/modules/storefrontCheckout/entities/CheckoutSessionDiscount.ts @@ -0,0 +1,24 @@ +import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer'; +import { CheckoutSession } from './CheckoutSession'; + +@Entity('checkout_session_discounts') +export class CheckoutSessionDiscount { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => CheckoutSession, session => session.discounts, { onDelete: 'CASCADE' }) + @JoinColumn() + session: CheckoutSession; + + @Column({ length: 32 }) + code: string; + + @Column({ + type: 'numeric', + precision: 12, + scale: 2, + transformer: new ColumnNumericTransformer() + }) + amountFiat: number; +} diff --git a/backend/src/modules/storefrontCheckout/entities/CheckoutSessionLine.ts b/backend/src/modules/storefrontCheckout/entities/CheckoutSessionLine.ts new file mode 100644 index 0000000..09ddcb6 --- /dev/null +++ b/backend/src/modules/storefrontCheckout/entities/CheckoutSessionLine.ts @@ -0,0 +1,51 @@ +import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer'; +import { DeliveryMode } from '../../product/types/DeliveryMode'; +import { CheckoutSession } from './CheckoutSession'; + +@Entity('checkout_session_lines') +export class CheckoutSessionLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => CheckoutSession, session => session.lines, { onDelete: 'CASCADE' }) + @JoinColumn() + session: CheckoutSession; + + @Column({ type: 'uuid' }) + variantId: string; + + @Column({ type: 'uuid' }) + productId: string; + + @Column() + productTitle: string; + + @Column() + variantTitle: string; + + @Column({ type: 'varchar', nullable: true }) + thumbnailUrl: string | null; + + @Column({ type: 'int' }) + qty: number; + + @Column({ + type: 'numeric', + precision: 12, + scale: 2, + transformer: new ColumnNumericTransformer() + }) + unitPriceFiat: number; + + @Column({ + type: 'numeric', + precision: 12, + scale: 2, + transformer: new ColumnNumericTransformer() + }) + lineSubtotalFiat: number; + + @Column({ type: 'enum', enum: DeliveryMode }) + deliveryMode: DeliveryMode; +} diff --git a/backend/src/modules/storefrontCheckout/services/CheckoutPaymentPollerService.spec.ts b/backend/src/modules/storefrontCheckout/services/CheckoutPaymentPollerService.spec.ts new file mode 100644 index 0000000..4ea6031 --- /dev/null +++ b/backend/src/modules/storefrontCheckout/services/CheckoutPaymentPollerService.spec.ts @@ -0,0 +1,145 @@ +import { Logger } from '@nestjs/common'; +import type { Repository } from 'typeorm'; +import type { Invoice } from '../../payment/entities/Invoice'; +import { PaymentMethod } from '../../payment/types/PaymentMethod'; +import type { OrderCreationService } from '../../order/services/OrderCreationService'; +import { CheckoutSession } from '../entities/CheckoutSession'; +import type { CheckoutPaymentPollerServiceTest } from '../types/CheckoutPaymentPollerServiceTest'; +import { CheckoutPaymentPollerService } from './CheckoutPaymentPollerService'; + +const buildPaidInvoice = () => ({ + paymentMethod: PaymentMethod.Xmr, + expectedTotalAtomic: '1000', + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + moneroDetails: { requiredConfirmations: 1 }, + payments: [{ amountAtomic: '1000', confirmations: 1 }] +}); + +const buildSession = (overrides: Partial = {}): CheckoutSession => + ({ + id: 'session-1', + invoice: buildPaidInvoice(), + ...overrides + }) as CheckoutSession; + +describe('CheckoutPaymentPollerService', () => { + let service: CheckoutPaymentPollerServiceTest; + let sessionRepo: { + createQueryBuilder: jest.Mock; + }; + let sessionQueryBuilder: { + innerJoinAndSelect: jest.Mock; + leftJoinAndSelect: jest.Mock; + leftJoin: jest.Mock; + where: jest.Mock; + andWhere: jest.Mock; + getMany: jest.Mock; + }; + let orderCreationService: { + createFromPaidSession: jest.Mock; + }; + let errorLogSpy: jest.SpiedFunction; + + beforeEach(() => { + errorLogSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + + sessionQueryBuilder = { + innerJoinAndSelect: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]) + }; + + sessionRepo = { + createQueryBuilder: jest.fn().mockReturnValue(sessionQueryBuilder) + }; + + orderCreationService = { + createFromPaidSession: jest.fn().mockResolvedValue(undefined) + }; + + service = new CheckoutPaymentPollerService( + sessionRepo as unknown as Repository, + orderCreationService as unknown as OrderCreationService + ) as unknown as CheckoutPaymentPollerServiceTest; + }); + + afterEach(() => { + errorLogSpy.mockRestore(); + }); + + it('does nothing when there are no open checkout sessions', async () => { + sessionQueryBuilder.getMany.mockResolvedValue([]); + + await service.pollCheckoutSessions(); + + expect(orderCreationService.createFromPaidSession).not.toHaveBeenCalled(); + }); + + it('skips sessions without an invoice', async () => { + sessionQueryBuilder.getMany.mockResolvedValue([buildSession({ invoice: undefined })]); + + await service.pollCheckoutSessions(); + + expect(orderCreationService.createFromPaidSession).not.toHaveBeenCalled(); + }); + + it('skips sessions whose invoice is not paid sufficiently', async () => { + sessionQueryBuilder.getMany.mockResolvedValue([ + buildSession({ + invoice: { + ...buildPaidInvoice(), + payments: [{ amountAtomic: '100', confirmations: 1 }] + } as Invoice + }) + ]); + + await service.pollCheckoutSessions(); + + expect(orderCreationService.createFromPaidSession).not.toHaveBeenCalled(); + }); + + it('creates an order for paid checkout sessions', async () => { + sessionQueryBuilder.getMany.mockResolvedValue([buildSession({ id: 'session-paid' })]); + + await service.pollCheckoutSessions(); + + expect(orderCreationService.createFromPaidSession).toHaveBeenCalledWith('session-paid'); + }); + + + it('creates orders only for paid sessions when the poll batch is mixed', async () => { + sessionQueryBuilder.getMany.mockResolvedValue([ + buildSession({ + id: 'session-unpaid', + invoice: { + ...buildPaidInvoice(), + payments: [{ amountAtomic: '100', confirmations: 1 }] + } as Invoice + }), + buildSession({ id: 'session-paid' }) + ]); + + await service.pollCheckoutSessions(); + + expect(orderCreationService.createFromPaidSession).toHaveBeenCalledTimes(1); + expect(orderCreationService.createFromPaidSession).toHaveBeenCalledWith('session-paid'); + }); + + it('continues processing other sessions when order creation fails for one session', async () => { + sessionQueryBuilder.getMany.mockResolvedValue([ + buildSession({ id: 'session-fail' }), + buildSession({ id: 'session-ok' }) + ]); + orderCreationService.createFromPaidSession + .mockRejectedValueOnce(new Error('create failed')) + .mockResolvedValueOnce(undefined); + + await service.pollCheckoutSessions(); + + expect(orderCreationService.createFromPaidSession).toHaveBeenNthCalledWith(1, 'session-fail'); + expect(orderCreationService.createFromPaidSession).toHaveBeenNthCalledWith(2, 'session-ok'); + }); +}); diff --git a/backend/src/modules/storefrontCheckout/services/CheckoutPaymentPollerService.ts b/backend/src/modules/storefrontCheckout/services/CheckoutPaymentPollerService.ts new file mode 100644 index 0000000..f3e271a --- /dev/null +++ b/backend/src/modules/storefrontCheckout/services/CheckoutPaymentPollerService.ts @@ -0,0 +1,57 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { getErrorMessage } from '../../../utils/getErrorMessage'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { deriveInvoiceState } from '../../../utils/invoice/deriveInvoiceState'; +import { OrderCreationService } from '../../order/services/OrderCreationService'; +import { CheckoutSession } from '../entities/CheckoutSession'; + +@Injectable() +export class CheckoutPaymentPollerService { + private readonly logger = new Logger(CheckoutPaymentPollerService.name); + + constructor( + @InjectRepository(CheckoutSession) + private readonly sessionRepo: Repository, + private readonly orderCreationService: OrderCreationService + ) {} + + @Cron(CronExpression.EVERY_10_SECONDS) + private async pollCheckoutSessions(): Promise { + const now = new Date(); + + const sessions = await this.sessionRepo + .createQueryBuilder('session') + .innerJoinAndSelect('session.invoice', 'invoice') + .leftJoinAndSelect('invoice.moneroDetails', 'moneroDetails') + .leftJoinAndSelect('invoice.payments', 'payment') + .leftJoin('session.order', 'order') + .where('session.cancelledAt IS NULL') + .andWhere('order.id IS NULL') + .andWhere('invoice.expiresAt > :now', { now }) + .getMany(); + + for (const session of sessions) { + const invoice = session.invoice; + + if (!invoice) { + continue; + } + + const { isPaidSufficient } = deriveInvoiceState(invoice); + + if (!isPaidSufficient) { + continue; + } + + try { + await this.orderCreationService.createFromPaidSession(session.id); + } catch (error) { + this.logger.error( + `Failed to create order for paid checkout session ${session.id}: ${getErrorMessage(error)}` + ); + } + } + } +} diff --git a/backend/src/modules/storefrontCheckout/services/CheckoutSessionService.spec.ts b/backend/src/modules/storefrontCheckout/services/CheckoutSessionService.spec.ts new file mode 100644 index 0000000..9c21e21 --- /dev/null +++ b/backend/src/modules/storefrontCheckout/services/CheckoutSessionService.spec.ts @@ -0,0 +1,287 @@ +import { BadRequestException } from '@nestjs/common'; +import type { Repository } from 'typeorm'; +import { DeliveryMode } from '../../product/types/DeliveryMode'; +import type { Invoice } from '../../payment/entities/Invoice'; +import { InvoiceReason } from '../../payment/types/InvoiceReason'; +import { PaymentMethod } from '../../payment/types/PaymentMethod'; +import type { InvoiceService } from '../../payment/services/InvoiceService'; +import type { CookieCartSummary } from '../../storefrontCart/types/CookieCartSummary'; +import { CheckoutSession } from '../entities/CheckoutSession'; +import { CheckoutSessionDiscount } from '../entities/CheckoutSessionDiscount'; +import { CheckoutSessionLine } from '../entities/CheckoutSessionLine'; +import { CheckoutSessionService } from './CheckoutSessionService'; + +jest.mock('node:crypto', () => ({ + randomUUID: () => 'session-uuid' +})); + +const buildSummary = (overrides: Partial = {}): CookieCartSummary => + ({ + cartExtended: [ + { + id: 'variant-1', + productId: 'product-1', + productTitle: 'Product', + title: 'Variant', + price: 10, + deliveryMode: DeliveryMode.Auto, + stockAvailable: 5, + stockForSession: 5, + thumbnailUrl: null, + images: [], + qty: 1, + lineSubtotal: 10, + stockIssueMessage: null + } + ], + cartSubtotal: 10, + discounts: [{ code: 'SAVE1', amount: 1, issueMessage: null }], + cartDiscountTotal: 1, + cartTotalPrice: 9, + cartTotalXmr: '0.06000000', + fiatPerXmr: 150, + hasManualLines: false, + hasAutoLines: true, + cartTotalIssueMessage: null, + hasIssues: false, + ...overrides + }) as CookieCartSummary; + +const buildInvoice = (overrides: Partial = {}): Invoice => + ({ + id: 'invoice-1', + reason: InvoiceReason.Checkout, + paymentMethod: PaymentMethod.Xmr, + amountFiat: 9, + fiatCurrency: 'USD', + paymentAddress: '4CheckoutMoneroPaymentAddressExample', + expectedTotalAtomic: '60000000000', + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + payments: [], + moneroDetails: { + paymentAddressIndex: 1, + fiatPerXmrAtCreation: 150, + requiredConfirmations: 1 + }, + createdAt: new Date('2025-01-01T00:00:00.000Z'), + ...overrides + }) as Invoice; + +describe('CheckoutSessionService', () => { + let service: CheckoutSessionService; + let sessionRepo: { + create: jest.Mock; + save: jest.Mock; + update: jest.Mock; + findOne: jest.Mock; + }; + let lineRepo: { + create: jest.Mock; + }; + let discountRepo: { + create: jest.Mock; + }; + let invoiceService: { + issueInvoice: jest.Mock; + }; + + beforeEach(() => { + sessionRepo = { + create: jest.fn(data => data), + save: jest.fn(async (session: CheckoutSession) => session), + update: jest.fn().mockResolvedValue(undefined), + findOne: jest.fn().mockResolvedValue(null) + }; + + lineRepo = { + create: jest.fn(data => data) + }; + + discountRepo = { + create: jest.fn(data => data) + }; + + invoiceService = { + issueInvoice: jest.fn().mockResolvedValue(buildInvoice()) + }; + + service = new CheckoutSessionService( + sessionRepo as unknown as Repository, + lineRepo as unknown as Repository, + discountRepo as unknown as Repository, + invoiceService as unknown as InvoiceService + ); + }); + + describe('createFromCartSummary', () => { + it('rejects an empty cart', async () => { + await expect(service.createFromCartSummary(buildSummary({ cartExtended: [] }))).rejects.toThrow( + new BadRequestException('Your cart is empty') + ); + }); + + it('rejects carts that still have unresolved issues', async () => { + await expect(service.createFromCartSummary(buildSummary({ hasIssues: true }))).rejects.toThrow( + new BadRequestException('Resolve cart issues before paying') + ); + }); + + it('creates a session, invoice, lines, and valid discounts from the cart summary', async () => { + const summary = buildSummary({ + discounts: [ + { code: 'SAVE1', amount: 1, issueMessage: null }, + { code: 'BAD', amount: null, issueMessage: 'Invalid discount code' }, + { code: 'ZERO', amount: 0, issueMessage: null } + ] + }); + + const session = await service.createFromCartSummary(summary); + + expect(invoiceService.issueInvoice).toHaveBeenCalledWith({ + paymentMethod: PaymentMethod.Xmr, + reason: InvoiceReason.Checkout, + contextId: 'session-uuid', + amountFiat: 9 + }); + expect(lineRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + variantId: 'variant-1', + productId: 'product-1', + productTitle: 'Product', + variantTitle: 'Variant', + qty: 1, + unitPriceFiat: 10, + lineSubtotalFiat: 10, + deliveryMode: DeliveryMode.Auto + }) + ); + expect(discountRepo.create).toHaveBeenCalledTimes(1); + expect(discountRepo.create).toHaveBeenCalledWith({ + code: 'SAVE1', + amountFiat: 1 + }); + expect(sessionRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'session-uuid', + invoice: buildInvoice(), + lines: [expect.objectContaining({ variantId: 'variant-1' })], + discounts: [{ code: 'SAVE1', amountFiat: 1 }] + }) + ); + expect(sessionRepo.save).toHaveBeenCalled(); + expect(session).toEqual( + expect.objectContaining({ + id: 'session-uuid' + }) + ); + }); + + it('creates a session without discounts when none apply', async () => { + await service.createFromCartSummary( + buildSummary({ + discounts: [{ code: 'BAD', amount: null, issueMessage: 'Invalid discount code' }] + }) + ); + + expect(discountRepo.create).not.toHaveBeenCalled(); + expect(sessionRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + discounts: [] + }) + ); + }); + }); + + describe('findById', () => { + it('loads a session with checkout relations', async () => { + const session = { id: 'session-1' } as CheckoutSession; + sessionRepo.findOne.mockResolvedValue(session); + + await expect(service.findById('session-1')).resolves.toBe(session); + expect(sessionRepo.findOne).toHaveBeenCalledWith({ + where: { id: 'session-1' }, + relations: ['lines', 'discounts', 'invoice', 'invoice.moneroDetails', 'invoice.payments'] + }); + }); + }); + + describe('cancelSession', () => { + it('does nothing when the session does not exist', async () => { + sessionRepo.findOne.mockResolvedValue(null); + + await service.cancelSession('missing-session'); + + expect(sessionRepo.update).not.toHaveBeenCalled(); + }); + + it('does not cancel sessions that are already cancelled', async () => { + sessionRepo.findOne.mockResolvedValue({ + id: 'session-1', + cancelledAt: new Date('2020-01-01T00:00:00.000Z'), + invoice: buildInvoice() + }); + + await service.cancelSession('session-1'); + + expect(sessionRepo.update).not.toHaveBeenCalled(); + }); + + it('does not cancel sessions whose invoice is already paid', async () => { + sessionRepo.findOne.mockResolvedValue({ + id: 'session-1', + cancelledAt: null, + invoice: { + ...buildInvoice(), + expectedTotalAtomic: '60000000000', + payments: [{ amountAtomic: '60000000000', confirmations: 1 }] + } + }); + + await service.cancelSession('session-1'); + + expect(sessionRepo.update).not.toHaveBeenCalled(); + }); + + it('does not cancel sessions without an invoice', async () => { + sessionRepo.findOne.mockResolvedValue({ + id: 'session-1', + cancelledAt: null, + invoice: undefined + }); + + await service.cancelSession('session-1'); + + expect(sessionRepo.update).not.toHaveBeenCalled(); + }); + + it('cancels expired but unpaid sessions that are still open for payment', async () => { + sessionRepo.findOne.mockResolvedValue({ + id: 'session-1', + cancelledAt: null, + invoice: buildInvoice({ + expiresAt: new Date('2020-01-01T00:00:00.000Z') + }) + }); + + await service.cancelSession('session-1'); + + expect(sessionRepo.update).toHaveBeenCalledWith('session-1', { + cancelledAt: expect.any(Date) + }); + }); + + it('cancels open unpaid sessions', async () => { + sessionRepo.findOne.mockResolvedValue({ + id: 'session-1', + cancelledAt: null, + invoice: buildInvoice() + }); + + await service.cancelSession('session-1'); + + expect(sessionRepo.update).toHaveBeenCalledWith('session-1', { + cancelledAt: expect.any(Date) + }); + }); + }); +}); diff --git a/backend/src/modules/storefrontCheckout/services/CheckoutSessionService.ts b/backend/src/modules/storefrontCheckout/services/CheckoutSessionService.ts new file mode 100644 index 0000000..2a4f983 --- /dev/null +++ b/backend/src/modules/storefrontCheckout/services/CheckoutSessionService.ts @@ -0,0 +1,102 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { randomUUID } from 'node:crypto'; +import { Repository } from 'typeorm'; +import type { CookieCartSummary } from '../../storefrontCart/types/CookieCartSummary'; +import { deriveCheckoutSessionState } from '../../../utils/checkout/deriveCheckoutSessionState'; +import { InvoiceReason } from '../../payment/types/InvoiceReason'; +import { InvoiceService } from '../../payment/services/InvoiceService'; +import { PaymentMethod } from '../../payment/types/PaymentMethod'; +import { CheckoutSessionDiscount } from '../entities/CheckoutSessionDiscount'; +import { CheckoutSessionLine } from '../entities/CheckoutSessionLine'; +import { CheckoutSession } from '../entities/CheckoutSession'; + +@Injectable() +export class CheckoutSessionService { + constructor( + @InjectRepository(CheckoutSession) + private readonly sessionRepo: Repository, + @InjectRepository(CheckoutSessionLine) + private readonly lineRepo: Repository, + @InjectRepository(CheckoutSessionDiscount) + private readonly discountRepo: Repository, + private readonly invoiceService: InvoiceService + ) {} + + async findById(id: string): Promise { + return this.sessionRepo.findOne({ + where: { id }, + relations: ['lines', 'discounts', 'invoice', 'invoice.moneroDetails', 'invoice.payments'] + }); + } + + async createFromCartSummary(summary: CookieCartSummary): Promise { + if (summary.cartExtended.length === 0) { + throw new BadRequestException('Your cart is empty'); + } + + if (summary.hasIssues) { + throw new BadRequestException('Resolve cart issues before paying'); + } + + const sessionId = randomUUID(); + + const invoice = await this.invoiceService.issueInvoice({ + paymentMethod: PaymentMethod.Xmr, + reason: InvoiceReason.Checkout, + contextId: sessionId, + amountFiat: summary.cartTotalPrice + }); + + const lines = summary.cartExtended.map(line => + this.lineRepo.create({ + variantId: line.id, + productId: line.productId, + productTitle: line.productTitle, + variantTitle: line.title, + thumbnailUrl: line.thumbnailUrl, + qty: line.qty, + unitPriceFiat: line.price, + lineSubtotalFiat: line.lineSubtotal, + deliveryMode: line.deliveryMode + }) + ); + + const discounts = summary.discounts + .filter(d => d.amount !== null && d.amount > 0 && !d.issueMessage) + .map(d => + this.discountRepo.create({ + code: d.code, + amountFiat: d.amount! + }) + ); + + const session = this.sessionRepo.create({ + id: sessionId, + invoice, + lines, + discounts + }); + + return this.sessionRepo.save(session); + } + + async cancelSession(sessionId: string): Promise { + const session = await this.sessionRepo.findOne({ + where: { id: sessionId }, + relations: ['invoice', 'invoice.moneroDetails', 'invoice.payments'] + }); + + if (!session) { + return; + } + + const { isOpenForPayment } = deriveCheckoutSessionState(session); + + if (!isOpenForPayment) { + return; + } + + await this.sessionRepo.update(session.id, { cancelledAt: new Date() }); + } +} diff --git a/backend/src/modules/storefrontCheckout/services/StorefrontCheckoutViewService.spec.ts b/backend/src/modules/storefrontCheckout/services/StorefrontCheckoutViewService.spec.ts new file mode 100644 index 0000000..245c635 --- /dev/null +++ b/backend/src/modules/storefrontCheckout/services/StorefrontCheckoutViewService.spec.ts @@ -0,0 +1,131 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { XMR_ATOMIC_PER_XMR } from '../../../consts/xmrAtomicPerXmr'; +import * as toStorefrontInvoiceViewModule from '../../../utils/invoice/toStorefrontInvoiceView'; +import type { StorefrontInvoiceView } from '../../storefrontCore/types/StorefrontInvoiceView'; +import type { CheckoutSession } from '../entities/CheckoutSession'; +import type { Invoice } from '../../payment/entities/Invoice'; +import { PaymentMethod } from '../../payment/types/PaymentMethod'; +import { InvoiceReason } from '../../payment/types/InvoiceReason'; +import { DeliveryMode } from '../../product/types/DeliveryMode'; +import { StorefrontCheckoutViewService } from './StorefrontCheckoutViewService'; + +const oneXmrAtomic = XMR_ATOMIC_PER_XMR.toString(); + +const buildInvoice = (overrides: Partial = {}): Invoice => + ({ + reason: InvoiceReason.Checkout, + paymentMethod: PaymentMethod.Xmr, + amountFiat: 9, + fiatCurrency: 'USD', + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + paymentAddress: '4CheckoutMoneroPaymentAddressExample', + expectedTotalAtomic: oneXmrAtomic, + payments: [], + moneroDetails: { + paymentAddressIndex: 1, + fiatPerXmrAtCreation: 150, + requiredConfirmations: 1 + }, + ...overrides + }) as Invoice; + +const buildSession = (overrides: Partial = {}): CheckoutSession => + ({ + id: 'session-1', + cancelledAt: null, + lines: [ + { + productId: 'product-1', + variantId: 'variant-1', + productTitle: 'Product', + variantTitle: 'Variant', + thumbnailUrl: null, + qty: 2, + unitPriceFiat: 5, + lineSubtotalFiat: 10, + deliveryMode: DeliveryMode.Auto + } + ], + discounts: [{ code: 'SAVE1', amountFiat: 1 }], + invoice: buildInvoice(), + ...overrides + }) as CheckoutSession; + +describe('StorefrontCheckoutViewService', () => { + let service: StorefrontCheckoutViewService; + let toStorefrontInvoiceViewSpy: jest.SpiedFunction; + + const checkoutInvoiceView = { + cryptoCurrency: 'XMR', + amountFiat: 9 + } as unknown as StorefrontInvoiceView; + + beforeEach(() => { + service = new StorefrontCheckoutViewService(); + toStorefrontInvoiceViewSpy = jest + .spyOn(toStorefrontInvoiceViewModule, 'toStorefrontInvoiceView') + .mockResolvedValue(checkoutInvoiceView); + }); + + afterEach(() => { + toStorefrontInvoiceViewSpy.mockRestore(); + }); + + it('maps checkout totals, lines, discounts, and invoice', async () => { + const session = buildSession(); + + const view = await service.toCheckoutView(session); + + expect(view.totals).toEqual({ + subtotalFiat: 10, + discountTotalFiat: 1, + totalFiat: 9 + }); + expect(view.lines).toEqual([ + { + linkHref: '/shop/products/product-1/variants/variant-1', + thumbnailUrl: null, + productTitle: 'Product', + variantTitle: 'Variant', + qty: 2, + unitPriceFiat: 5, + lineSubtotalFiat: 10, + deliveryMode: DeliveryMode.Auto + } + ]); + expect(view.discounts).toEqual([{ code: 'SAVE1', amountFiat: 1 }]); + expect(view.checkoutInvoice).toBe(checkoutInvoiceView); + expect(view.refreshHref).toBe('/shop/checkout'); + expect(view.cancelCheckoutAction).toBe('/shop/checkout/cancel'); + }); + + it('treats missing lines and discounts as empty collections', async () => { + const session = buildSession({ lines: [], discounts: [] }); + + const view = await service.toCheckoutView(session); + + expect(view.totals).toEqual({ + subtotalFiat: 0, + discountTotalFiat: 0, + totalFiat: 9 + }); + expect(view.lines).toEqual([]); + expect(view.discounts).toEqual([]); + }); + + it('delegates invoice mapping to toStorefrontInvoiceView', async () => { + const invoice = buildInvoice(); + const session = buildSession({ invoice }); + + await service.toCheckoutView(session); + + expect(toStorefrontInvoiceViewSpy).toHaveBeenCalledWith(invoice); + }); + + it('throws when the checkout session has no payment invoice', async () => { + const session = buildSession({ invoice: undefined }); + + await expect(service.toCheckoutView(session)).rejects.toBeInstanceOf(InternalServerErrorException); + expect(toStorefrontInvoiceViewSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/modules/storefrontCheckout/services/StorefrontCheckoutViewService.ts b/backend/src/modules/storefrontCheckout/services/StorefrontCheckoutViewService.ts new file mode 100644 index 0000000..7a8669b --- /dev/null +++ b/backend/src/modules/storefrontCheckout/services/StorefrontCheckoutViewService.ts @@ -0,0 +1,55 @@ +import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { deriveCheckoutTotals } from '../../../utils/checkout/deriveCheckoutTotals'; +import { toStorefrontDiscountView } from '../../../utils/storefront/toStorefrontDiscountView'; +import { toStorefrontInvoiceView } from '../../../utils/invoice/toStorefrontInvoiceView'; +import type { CheckoutSession } from '../entities/CheckoutSession'; +import type { CheckoutSessionLine } from '../entities/CheckoutSessionLine'; +import type { StorefrontCheckoutLineView } from '../types/StorefrontCheckoutLineView'; +import type { StorefrontCheckoutView } from '../types/StorefrontCheckoutView'; + +@Injectable() +export class StorefrontCheckoutViewService { + async toCheckoutView(session: CheckoutSession): Promise { + const invoice = session.invoice; + + if (!invoice) { + throw new InternalServerErrorException('Checkout session is missing payment invoice'); + } + + return { + refreshHref: '/shop/checkout', + cancelCheckoutAction: '/shop/checkout/cancel', + totals: deriveCheckoutTotals({ + lines: session.lines, + discounts: session.discounts, + invoice + }), + checkoutInvoice: await toStorefrontInvoiceView(invoice), + lines: (session.lines ?? []).map(line => this.toLineView(line)), + discounts: (session.discounts ?? []).map(toStorefrontDiscountView) + }; + } + + private toLineView({ + productId, + variantId, + productTitle, + variantTitle, + thumbnailUrl, + qty, + unitPriceFiat, + lineSubtotalFiat, + deliveryMode + }: CheckoutSessionLine): StorefrontCheckoutLineView { + return { + linkHref: `/shop/products/${productId}/variants/${variantId}`, + thumbnailUrl, + productTitle, + variantTitle, + qty, + unitPriceFiat, + lineSubtotalFiat, + deliveryMode + }; + } +} diff --git a/backend/src/modules/storefrontCheckout/types/CheckoutPaymentPollerServiceTest.ts b/backend/src/modules/storefrontCheckout/types/CheckoutPaymentPollerServiceTest.ts new file mode 100644 index 0000000..697e4fb --- /dev/null +++ b/backend/src/modules/storefrontCheckout/types/CheckoutPaymentPollerServiceTest.ts @@ -0,0 +1,3 @@ +export type CheckoutPaymentPollerServiceTest = { + pollCheckoutSessions: () => Promise; +}; diff --git a/backend/src/modules/storefrontCheckout/types/StorefrontCheckoutLineView.ts b/backend/src/modules/storefrontCheckout/types/StorefrontCheckoutLineView.ts new file mode 100644 index 0000000..973fd4b --- /dev/null +++ b/backend/src/modules/storefrontCheckout/types/StorefrontCheckoutLineView.ts @@ -0,0 +1,12 @@ +import type { DeliveryMode } from '../../product/types/DeliveryMode'; + +export type StorefrontCheckoutLineView = { + linkHref: string | null; + thumbnailUrl: string | null; + productTitle: string; + variantTitle: string; + qty: number; + unitPriceFiat: number; + lineSubtotalFiat: number; + deliveryMode: DeliveryMode; +}; diff --git a/backend/src/modules/storefrontCheckout/types/StorefrontCheckoutView.ts b/backend/src/modules/storefrontCheckout/types/StorefrontCheckoutView.ts new file mode 100644 index 0000000..dcbbeb3 --- /dev/null +++ b/backend/src/modules/storefrontCheckout/types/StorefrontCheckoutView.ts @@ -0,0 +1,13 @@ +import type { StorefrontInvoiceView } from '../../storefrontCore/types/StorefrontInvoiceView'; +import type { StorefrontDiscountView } from '../../storefrontCore/types/StorefrontDiscountView'; +import type { CheckoutTotals } from '../../../utils/checkout/types/CheckoutTotals'; +import type { StorefrontCheckoutLineView } from './StorefrontCheckoutLineView'; + +export type StorefrontCheckoutView = { + refreshHref: string; + cancelCheckoutAction: string; + totals: CheckoutTotals; + checkoutInvoice: StorefrontInvoiceView; + lines: StorefrontCheckoutLineView[]; + discounts: StorefrontDiscountView[]; +}; diff --git a/backend/src/modules/storefrontCore/StorefrontCoreModule.ts b/backend/src/modules/storefrontCore/StorefrontCoreModule.ts new file mode 100644 index 0000000..254f66e --- /dev/null +++ b/backend/src/modules/storefrontCore/StorefrontCoreModule.ts @@ -0,0 +1,51 @@ +import { Module } from '@nestjs/common'; +import { EncryptionModule } from '../encryption/EncryptionModule'; +import { ShopSettingsModule } from '../shopSettings/ShopSettingsModule'; +import { XmrRateModule } from '../xmrRate/XmrRateModule'; +import { StorefrontErrorController } from './controllers/StorefrontErrorController'; +import { StorefrontPreferencesController } from './controllers/StorefrontPreferencesController'; +import { StorefrontExceptionFilter } from './filters/StorefrontExceptionFilter'; +import { StorefrontCaptchaCookieService } from './services/StorefrontCaptchaCookieService'; +import { StorefrontCaptchaService } from './services/StorefrontCaptchaService'; +import { StorefrontCartCookieService } from './services/StorefrontCartCookieService'; +import { StorefrontCheckoutSessionCookieService } from './services/StorefrontCheckoutSessionCookieService'; +import { StorefrontDiscountCookieService } from './services/StorefrontDiscountCookieService'; +import { StorefrontErrorCookieService } from './services/StorefrontErrorCookieService'; +import { StorefrontFeedbackCookieService } from './services/StorefrontFeedbackCookieService'; +import { StorefrontOrderAuthCookieService } from './services/StorefrontOrderAuthCookieService'; +import { StorefrontShopViewService } from './services/StorefrontShopViewService'; +import { StorefrontSignedCookieService } from './services/StorefrontSignedCookieService'; +import { StorefrontThemeCookieService } from './services/StorefrontThemeCookieService'; + +@Module({ + imports: [ShopSettingsModule, XmrRateModule, EncryptionModule], + controllers: [StorefrontErrorController, StorefrontPreferencesController], + providers: [ + StorefrontSignedCookieService, + StorefrontCaptchaCookieService, + StorefrontCaptchaService, + StorefrontCartCookieService, + StorefrontCheckoutSessionCookieService, + StorefrontDiscountCookieService, + StorefrontFeedbackCookieService, + StorefrontOrderAuthCookieService, + StorefrontErrorCookieService, + StorefrontShopViewService, + StorefrontThemeCookieService, + StorefrontExceptionFilter + ], + exports: [ + StorefrontCartCookieService, + StorefrontCaptchaCookieService, + StorefrontCaptchaService, + StorefrontCheckoutSessionCookieService, + StorefrontDiscountCookieService, + StorefrontFeedbackCookieService, + StorefrontOrderAuthCookieService, + StorefrontErrorCookieService, + StorefrontShopViewService, + StorefrontThemeCookieService, + StorefrontExceptionFilter + ] +}) +export class StorefrontCoreModule {} diff --git a/backend/src/modules/storefrontCore/controllers/StorefrontErrorController.ts b/backend/src/modules/storefrontCore/controllers/StorefrontErrorController.ts new file mode 100644 index 0000000..2a6c13d --- /dev/null +++ b/backend/src/modules/storefrontCore/controllers/StorefrontErrorController.ts @@ -0,0 +1,29 @@ +import { Controller, Get, Req, Res } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { StorefrontErrorCookieService } from '../services/StorefrontErrorCookieService'; +import { StorefrontShopViewService } from '../services/StorefrontShopViewService'; + +@Controller() +export class StorefrontErrorController { + constructor( + private readonly errorCookieService: StorefrontErrorCookieService, + private readonly shopViewService: StorefrontShopViewService + ) {} + + @Get('shop/error') + async shopError(@Req() req: Request, @Res() res: Response) { + const { statusCode, message } = this.errorCookieService.getError(req, res); + + const shopLocals = await this.shopViewService.buildShopRenderLocals(req, res, { + title: 'Error', + metaDescription: message + }); + + return res.render('shop-error', { + layout: 'layouts/shop-minimal', + errorStatusCode: statusCode, + errorMessage: message, + ...shopLocals + }); + } +} diff --git a/backend/src/modules/storefrontCore/controllers/StorefrontPreferencesController.ts b/backend/src/modules/storefrontCore/controllers/StorefrontPreferencesController.ts new file mode 100644 index 0000000..1d9c8a6 --- /dev/null +++ b/backend/src/modules/storefrontCore/controllers/StorefrontPreferencesController.ts @@ -0,0 +1,19 @@ +import { Body, Controller, HttpStatus, Post, Req, Res, UseFilters } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { safeInternalShopRedirectPath } from '../../../utils/safeInternalShopRedirectPath'; +import { SetThemePreferenceDto } from '../dto/SetThemePreferenceDto'; +import { StorefrontExceptionFilter } from '../filters/StorefrontExceptionFilter'; +import { StorefrontThemeCookieService } from '../services/StorefrontThemeCookieService'; + +@Controller() +@UseFilters(StorefrontExceptionFilter) +export class StorefrontPreferencesController { + constructor(private readonly themeCookieService: StorefrontThemeCookieService) {} + + @Post('shop/preferences/theme') + setTheme(@Req() req: Request, @Res() res: Response, @Body() { theme }: SetThemePreferenceDto): void { + this.themeCookieService.setTheme(req, res, theme); + + res.redirect(HttpStatus.FOUND, safeInternalShopRedirectPath(req, '/')); + } +} diff --git a/backend/src/modules/storefrontCore/dto/SetThemePreferenceDto.ts b/backend/src/modules/storefrontCore/dto/SetThemePreferenceDto.ts new file mode 100644 index 0000000..f9c3b72 --- /dev/null +++ b/backend/src/modules/storefrontCore/dto/SetThemePreferenceDto.ts @@ -0,0 +1,8 @@ +import { IsIn, IsNotEmpty } from 'class-validator'; +import type { StorefrontThemePreference } from '../types/StorefrontThemePreference'; + +export class SetThemePreferenceDto { + @IsNotEmpty() + @IsIn(['light', 'dark']) + theme: StorefrontThemePreference; +} diff --git a/backend/src/modules/storefrontCore/filters/StorefrontExceptionFilter.ts b/backend/src/modules/storefrontCore/filters/StorefrontExceptionFilter.ts new file mode 100644 index 0000000..a34e9d8 --- /dev/null +++ b/backend/src/modules/storefrontCore/filters/StorefrontExceptionFilter.ts @@ -0,0 +1,93 @@ +import { + ArgumentsHost, + BadRequestException, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, + UnauthorizedException +} from '@nestjs/common'; +import { ThrottlerException } from '@nestjs/throttler'; +import { STATUS_CODES } from 'node:http'; +import type { Request, Response } from 'express'; +import { StorefrontErrorCookieService } from '../services/StorefrontErrorCookieService'; +import { StorefrontFeedbackCookieService } from '../services/StorefrontFeedbackCookieService'; +import { getHttpExceptionUserMessage } from '../utils/getHttpExceptionUserMessage'; +import { safeInternalShopRedirectPath } from '../../../utils/safeInternalShopRedirectPath'; + +@Catch() +export class StorefrontExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(StorefrontExceptionFilter.name); + + constructor( + private readonly feedbackCookieService: StorefrontFeedbackCookieService, + private readonly errorCookieService: StorefrontErrorCookieService + ) {} + + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const res = ctx.getResponse(); + const req = ctx.getRequest(); + + if (exception instanceof BadRequestException) { + this.feedbackCookieService.setFeedback(req, res, { + type: 'error', + text: getHttpExceptionUserMessage(exception, 'Invalid request.') + }); + + res.redirect(HttpStatus.FOUND, safeInternalShopRedirectPath(req)); + + return; + } + + if (exception instanceof ThrottlerException) { + this.feedbackCookieService.setFeedback(req, res, { + type: 'error', + text: 'Too many requests. Try again in a moment.' + }); + + res.redirect(HttpStatus.FOUND, safeInternalShopRedirectPath(req)); + + return; + } + + if (exception instanceof UnauthorizedException) { + this.feedbackCookieService.setFeedback(req, res, { + type: 'error', + text: getHttpExceptionUserMessage( + exception, + 'Order auth failed or expired, please enter your access token again.' + ) + }); + + res.redirect(HttpStatus.FOUND, '/shop/check-order'); + + return; + } + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let message = 'Something went wrong.'; + + if (exception instanceof HttpException) { + status = exception.getStatus(); + + if (status === HttpStatus.SERVICE_UNAVAILABLE) { + message = getHttpExceptionUserMessage( + exception, + 'Something went wrong. Please try again in a few minutes.' + ); + } else if (status === HttpStatus.NOT_FOUND) { + message = getHttpExceptionUserMessage(exception, 'Not Found'); + } else { + message = STATUS_CODES[status] ?? 'Something went wrong.'; + } + } else { + this.logger.error(exception); + } + + this.errorCookieService.setError(req, res, { statusCode: status, message }); + + res.redirect(HttpStatus.FOUND, '/shop/error'); + } +} diff --git a/backend/src/modules/storefrontCore/guards/OrderAuthGuard.ts b/backend/src/modules/storefrontCore/guards/OrderAuthGuard.ts new file mode 100644 index 0000000..11e805f --- /dev/null +++ b/backend/src/modules/storefrontCore/guards/OrderAuthGuard.ts @@ -0,0 +1,21 @@ +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { StorefrontOrderAuthCookieService } from '../services/StorefrontOrderAuthCookieService'; + +@Injectable() +export class OrderAuthGuard implements CanActivate { + constructor(private readonly orderAuthCookieService: StorefrontOrderAuthCookieService) {} + + canActivate(context: ExecutionContext): boolean { + const req = context.switchToHttp().getRequest(); + const res = context.switchToHttp().getResponse(); + + const orderId = req.params.orderId; + + if (typeof orderId !== 'string' || !this.orderAuthCookieService.isAuthorized(req, res, orderId)) { + throw new UnauthorizedException('Order auth failed or expired, please enter your access token again.'); + } + + return true; + } +} diff --git a/backend/src/modules/storefrontCore/public/css/storefront.css b/backend/src/modules/storefrontCore/public/css/storefront.css new file mode 100644 index 0000000..0e0d750 --- /dev/null +++ b/backend/src/modules/storefrontCore/public/css/storefront.css @@ -0,0 +1,1383 @@ +:root { + --sf-font-sans: + 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif; + --sf-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + --sf-bg: #ffffff; + --sf-surface: #ffffff; + --sf-surface-muted: #f5f7fa; + --sf-border: #dcdfe6; + --sf-border-light: #e4e7ed; + --sf-border-strong: #d4d7de; + --sf-text: #303133; + --sf-text-muted: #606266; + --sf-text-subtle: #909399; + --sf-link: #409eff; + --sf-link-hover: #337ecc; + --sf-accent: #409eff; + --sf-accent-hover: #79bbff; + --sf-on-accent: #ffffff; + --sf-accent-subtle: #ecf5ff; + --sf-accent-border: #c6e2ff; + --sf-success: #67c23a; + --sf-success-bg: #f0f9eb; + --sf-success-border: #d1edc4; + --sf-error: #f56c6c; + --sf-error-bg: #fef0f0; + --sf-error-border: #fcd3d3; + --sf-warning: #e6a23c; + --sf-warning-bg: #fdf6ec; + --sf-warning-border: #f8e3c5; + --sf-focus: #409eff; + --sf-shadow-card: none; + --sf-shadow: none; + --sf-shadow-md: 0 0 12px rgba(0, 0, 0, 0.12); + --sf-radius-sm: 2px; + --sf-radius: 4px; + --sf-radius-lg: 8px; + --sf-radius-full: 999px; + --sf-space-1: 4px; + --sf-space-2: 8px; + --sf-space-3: 12px; + --sf-space-4: 16px; + --sf-space-5: 20px; + --sf-space-6: 24px; + --sf-space-8: 32px; + --sf-space-10: 40px; + --sf-space-12: 48px; + --sf-container: 88rem; + --sf-split-sidebar: minmax(220px, 320px); + --sf-product-card-min: 11rem; + --sf-product-card-max: 22rem; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + --sf-bg: #0a0a0a; + --sf-surface: #141414; + --sf-surface-muted: #262727; + --sf-border: #4c4d4f; + --sf-border-light: #414243; + --sf-border-strong: #58585b; + --sf-text: #e5eaf3; + --sf-text-muted: #cfd3dc; + --sf-text-subtle: #a3a6ad; + --sf-link: #409eff; + --sf-link-hover: #66b1ff; + --sf-accent: #409eff; + --sf-accent-hover: #66b1ff; + --sf-on-accent: #ffffff; + --sf-accent-subtle: #18222b; + --sf-accent-border: #213d5b; + --sf-success: #67c23a; + --sf-success-bg: #1c2518; + --sf-success-border: #2d481f; + --sf-error: #f56c6c; + --sf-error-bg: #2a1d1d; + --sf-error-border: #582e2e; + --sf-warning: #e6a23c; + --sf-warning-bg: #292218; + --sf-warning-border: #533f20; + --sf-focus: #409eff; + --sf-shadow-card: none; + --sf-shadow: none; + --sf-shadow-md: 0 1px 3px rgba(0, 0, 0, 0.4); + } +} + +:root[data-theme='dark'] { + --sf-bg: #0a0a0a; + --sf-surface: #141414; + --sf-surface-muted: #262727; + --sf-border: #4c4d4f; + --sf-border-light: #414243; + --sf-border-strong: #58585b; + --sf-text: #e5eaf3; + --sf-text-muted: #cfd3dc; + --sf-text-subtle: #a3a6ad; + --sf-link: #409eff; + --sf-link-hover: #66b1ff; + --sf-accent: #409eff; + --sf-accent-hover: #66b1ff; + --sf-on-accent: #ffffff; + --sf-accent-subtle: #18222b; + --sf-accent-border: #213d5b; + --sf-success: #67c23a; + --sf-success-bg: #1c2518; + --sf-success-border: #2d481f; + --sf-error: #f56c6c; + --sf-error-bg: #2a1d1d; + --sf-error-border: #582e2e; + --sf-warning: #e6a23c; + --sf-warning-bg: #292218; + --sf-warning-border: #533f20; + --sf-focus: #409eff; + --sf-shadow-card: none; + --sf-shadow: none; + --sf-shadow-md: 0 1px 3px rgba(0, 0, 0, 0.4); +} + +:root[data-theme='light'] { + --sf-bg: #ffffff; + --sf-surface: #ffffff; + --sf-surface-muted: #f5f7fa; + --sf-border: #dcdfe6; + --sf-border-light: #e4e7ed; + --sf-border-strong: #d4d7de; + --sf-text: #303133; + --sf-text-muted: #606266; + --sf-text-subtle: #909399; + --sf-link: #409eff; + --sf-link-hover: #337ecc; + --sf-accent: #409eff; + --sf-accent-hover: #79bbff; + --sf-on-accent: #ffffff; + --sf-accent-subtle: #ecf5ff; + --sf-accent-border: #c6e2ff; + --sf-success: #67c23a; + --sf-success-bg: #f0f9eb; + --sf-success-border: #d1edc4; + --sf-error: #f56c6c; + --sf-error-bg: #fef0f0; + --sf-error-border: #fcd3d3; + --sf-warning: #e6a23c; + --sf-warning-bg: #fdf6ec; + --sf-warning-border: #f8e3c5; + --sf-focus: #409eff; + --sf-shadow-card: none; + --sf-shadow: none; + --sf-shadow-md: 0 0 12px rgba(0, 0, 0, 0.12); +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + color-scheme: light dark; +} + +body.sf-body { + margin: 0; + min-height: 100vh; + display: flex; + flex-direction: column; + font-family: var(--sf-font-sans); + font-size: 1rem; + line-height: 1.5; + color: var(--sf-text); + background: var(--sf-bg); +} + +img { + max-width: 100%; + height: auto; +} + +a { + color: var(--sf-link); + text-decoration-thickness: 1px; + text-underline-offset: 2px; +} + +a:hover { + color: var(--sf-link-hover); +} + +:focus-visible { + outline: 2px solid var(--sf-focus); + outline-offset: 2px; +} + +.sf-container { + width: min(100%, var(--sf-container)); + margin: 0 auto; + padding: 0 var(--sf-space-4); + flex: 1; + display: flex; + flex-direction: column; +} + +.sf-main { + flex: 1; + width: 100%; +} + +.sf-max-w-half { + width: 100%; + max-width: calc(var(--sf-container) / 2); +} + +.sf-stack { + display: flex; + flex-direction: column; + gap: var(--sf-space-4); +} + +.sf-stack--lg { + gap: var(--sf-space-8); +} + +.sf-split { + display: grid; + grid-template-columns: 1fr var(--sf-split-sidebar); + gap: var(--sf-space-6); + align-items: start; +} + +@media (max-width: 768px) { + .sf-container { + padding: 0 var(--sf-space-3); + } + + .sf-split { + grid-template-columns: 1fr; + } +} + +.sf-page-title { + margin: 0 0 var(--sf-space-3); + font-size: 1.5rem; + font-weight: 600; + line-height: 1.25; +} + +.sf-section-title { + margin: 0 0 var(--sf-space-3); + font-size: 1.1rem; + font-weight: 600; +} + +.sf-text-muted { + color: var(--sf-text-muted); +} + +.sf-text-subtle { + color: var(--sf-text-subtle); + font-size: 0.9rem; +} + +.sf-text-success { + color: var(--sf-success); + font-weight: 600; +} + +.sf-text-error { + color: var(--sf-error); +} + +.sf-text-warning { + color: var(--sf-warning); +} + +.sf-mono { + font-family: var(--sf-font-mono); + word-break: break-all; +} + +.sf-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.sf-header { + margin-bottom: var(--sf-space-4); + padding: var(--sf-space-3) 0; + border-bottom: 1px solid var(--sf-border); +} + +.sf-header__inner { + display: flex; + flex-wrap: wrap; + gap: var(--sf-space-4); + align-items: center; + justify-content: space-between; +} + +.sf-header__brand-group { + display: flex; + flex-wrap: wrap; + gap: var(--sf-space-4); + align-items: center; +} + +.sf-logo-link { + display: flex; + align-items: center; + color: inherit; + text-decoration: none; + font-weight: 600; +} + +.sf-logo-link:hover { + color: inherit; +} + +.sf-logo { + width: 200px; + max-width: 100%; + height: 50px; + object-fit: contain; + display: block; +} + +.sf-nav { + display: flex; + flex-wrap: wrap; + gap: var(--sf-space-3); + align-items: center; +} + +.sf-nav-link { + color: var(--sf-text-muted); + text-decoration: none; +} + +.sf-nav-link:hover { + color: var(--sf-link-hover); +} + +.sf-nav-link--active { + color: var(--sf-accent); + font-weight: 600; +} + +.sf-nav-order { + display: inline-flex; + align-items: center; + gap: var(--sf-space-1); +} + +.sf-rate { + font-size: 0.95rem; + color: var(--sf-text-muted); +} + +.sf-category-nav { + margin-bottom: var(--sf-space-5); + display: flex; + flex-wrap: wrap; + gap: var(--sf-space-3); + align-items: center; +} + +.sf-footer { + flex-shrink: 0; + margin-top: var(--sf-space-12); + padding: var(--sf-space-4) 0; + border-top: 1px solid var(--sf-border); + font-size: 0.95rem; + color: var(--sf-text-muted); +} + +.sf-footer--stacked { + margin-top: var(--sf-space-4); + border-top: none; + padding-top: 0; +} + +.sf-footer__row { + display: flex; + flex-wrap: wrap; + gap: var(--sf-space-4); + align-items: center; + justify-content: space-between; +} + +.sf-theme-switcher { + display: flex; + flex-wrap: wrap; + gap: var(--sf-space-2); + align-items: center; +} + +.sf-theme-switcher__label { + font-size: 0.9rem; + color: var(--sf-text-muted); +} + +.sf-theme-switcher__form { + display: flex; + flex-wrap: wrap; + gap: var(--sf-space-1); + margin: 0; +} + +.sf-card, +.sf-panel, +.sf-line-item { + border: 1px solid var(--sf-border-light); + border-radius: var(--sf-radius); + background: var(--sf-surface); + box-shadow: var(--sf-shadow-card); +} + +.sf-card { + padding: var(--sf-space-2); + display: flex; + flex-direction: column; + gap: var(--sf-space-2); +} + +.sf-panel { + padding: var(--sf-space-5); + align-self: start; +} + +.sf-info-box { + padding: var(--sf-space-3); + border: 1px solid var(--sf-border-light); + border-radius: var(--sf-radius); + background: var(--sf-surface-muted); + font-size: 0.9rem; + line-height: 1.45; + color: var(--sf-text-muted); +} + +.sf-info-box__title { + margin: 0 0 var(--sf-space-3); + font-weight: 600; + color: var(--sf-text); +} + +.sf-info-box p { + margin: 0; +} + +.sf-info-box p + p, +.sf-info-box p + .sf-info-box__title { + margin-top: var(--sf-space-3); +} + +.sf-alert { + margin: 0 0 var(--sf-space-4); + padding: var(--sf-space-3) var(--sf-space-4); + border-radius: var(--sf-radius); + border: 1px solid var(--sf-border); +} + +.sf-alert--success { + color: var(--sf-success); + background: var(--sf-success-bg); + border-color: var(--sf-success-border); +} + +.sf-alert--error { + color: var(--sf-error); + background: var(--sf-error-bg); + border-color: var(--sf-error-border); +} + +.sf-alert--warning { + color: var(--sf-warning); + background: var(--sf-warning-bg); + border-color: var(--sf-warning-border); +} + +.sf-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2rem; + padding: 0.5rem 0.9375rem; + border: 1px solid var(--sf-border); + border-radius: var(--sf-radius); + background: var(--sf-surface); + color: var(--sf-text-muted); + font: inherit; + font-size: 0.875rem; + cursor: pointer; + text-decoration: none; + transition: + color 0.1s, + background-color 0.1s, + border-color 0.1s; +} + +.sf-btn:hover { + color: var(--sf-accent); + background: var(--sf-accent-subtle); + border-color: var(--sf-accent-border); +} + +.sf-btn--primary { + background: var(--sf-accent); + border-color: var(--sf-accent); + color: var(--sf-on-accent); +} + +.sf-btn--primary:hover { + background: var(--sf-accent-hover); + border-color: var(--sf-accent-hover); + color: var(--sf-on-accent); +} + +.sf-btn--dismiss { + border-color: transparent; + background: transparent; + color: var(--sf-error); + min-height: 1.5rem; + min-width: 1.5rem; + padding: 0; + font-size: 1.125rem; + line-height: 1; +} + +.sf-btn--dismiss:hover { + background: var(--sf-error-bg); + border-color: transparent; + color: var(--sf-error); +} + +.sf-dismiss-form { + margin: 0; + flex-shrink: 0; +} + +.sf-discount-row { + display: flex; + align-items: center; + gap: 0; + margin-bottom: var(--sf-space-1); +} + +.sf-discount-row > .sf-dismiss-form + .sf-discount-row__code { + margin-left: var(--sf-space-1); +} + +.sf-discount-row__code + .sf-discount-row__amount { + margin-left: var(--sf-space-3); +} + +.sf-discount-row__code { + flex: 1; + min-width: 0; +} + +.sf-discount-row__amount { + flex-shrink: 0; +} + +.sf-btn--sm { + min-height: 1.5rem; + padding: 0.3125rem 0.6875rem; + font-size: 0.75rem; +} + +.sf-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.sf-field { + display: flex; + flex-direction: column; + gap: var(--sf-space-1); +} + +.sf-field--row { + flex-direction: row; + align-items: center; + flex-wrap: wrap; + gap: var(--sf-space-2); +} + +.sf-field__label { + font-size: 0.9rem; + font-weight: 600; +} + +.sf-input, +.sf-textarea { + width: 100%; + padding: 0.3125rem 0.6875rem; + border: none; + border-radius: var(--sf-radius); + background: var(--sf-surface); + color: var(--sf-text-muted); + font: inherit; + font-size: 0.875rem; + box-shadow: 0 0 0 1px var(--sf-border) inset; + transition: box-shadow 0.2s; +} + +.sf-input:hover, +.sf-textarea:hover { + box-shadow: 0 0 0 1px var(--sf-border-strong) inset; +} + +.sf-input:focus, +.sf-textarea:focus { + outline: none; + box-shadow: 0 0 0 1px var(--sf-focus) inset; +} + +.sf-input--qty { + width: 3.25rem; + padding: 0.1875rem 0.4375rem; + font-size: 0.75rem; +} + +.sf-textarea { + resize: vertical; + min-height: 6rem; +} + +.sf-form-row { + display: flex; + flex-wrap: wrap; + gap: var(--sf-space-3); + align-items: flex-end; +} + +.sf-form-col { + display: flex; + flex-direction: column; + gap: var(--sf-space-3); +} + +.sf-badge { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.4rem; + border-radius: var(--sf-radius-full); + background: var(--sf-accent); + color: var(--sf-on-accent); + font-size: 0.65rem; + font-weight: 600; + line-height: 1.2; + text-decoration: none; +} + +.sf-badge:hover { + color: var(--sf-on-accent); + background: var(--sf-accent-hover); +} + +.sf-back-link { + display: inline-block; + margin: 0 0 var(--sf-space-4); +} + +.sf-back-link a { + color: var(--sf-accent); + text-decoration: none; +} + +.sf-back-link a:hover { + color: var(--sf-link-hover); +} + +.sf-list-reset { + list-style: none; + padding: 0; + margin: 0; +} + +.sf-line-items { + display: flex; + flex-direction: column; + gap: var(--sf-space-4); +} + +.sf-line-item { + display: flex; + flex-wrap: wrap; + gap: var(--sf-space-3); + justify-content: space-between; + align-items: flex-start; + padding: var(--sf-space-3); +} + +.sf-line-item--removable { + container-type: inline-size; +} + +.sf-line-item--removable > form { + margin: 0; + flex-shrink: 0; +} + +@container (max-width: 31.25rem) { + .sf-line-item--removable > form { + flex-basis: 100%; + display: flex; + justify-content: flex-end; + } +} + +@media (max-width: 31.25rem) { + .sf-line-item--removable { + flex-direction: column; + } + + .sf-line-item--removable > form { + align-self: flex-end; + } +} + +.sf-line-item__main { + flex: 1; + min-width: 12.5rem; + display: flex; + gap: var(--sf-space-3); + align-items: flex-start; +} + +.sf-line-item__body { + flex: 1; + min-width: 0; +} + +.sf-line-item__title a { + color: inherit; + text-decoration: none; +} + +.sf-line-item__title a:hover { + color: var(--sf-link-hover); +} + +.sf-line-item__meta { + margin-top: 0.125rem; + font-size: 0.9rem; + line-height: 1.35; +} + +.sf-line-item__note { + margin-top: 0.125rem; +} + +.sf-line-item__note > p, +.sf-line-item__body > .sf-text-subtle { + margin: 0; +} + +.sf-line-item__body > .sf-discount-error { + margin: 0.125rem 0 0; +} + +.sf-line-item__body > .sf-form-row { + margin-top: var(--sf-space-2); +} + +.sf-thumb { + flex-shrink: 0; + width: 4rem; + height: 4rem; + border: 1px solid var(--sf-border-light); + border-radius: var(--sf-radius); + overflow: hidden; + background: var(--sf-surface-muted); +} + +.sf-thumb a { + display: block; + width: 100%; + height: 100%; + color: inherit; + text-decoration: none; +} + +.sf-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.sf-thumb__placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + font-size: 0.625rem; + color: var(--sf-text-muted); + text-align: center; + padding: var(--sf-space-1); +} + +.sf-totals-row { + display: flex; + justify-content: space-between; + gap: var(--sf-space-3); + margin-bottom: var(--sf-space-2); +} + +.sf-totals-row--grand { + margin-top: var(--sf-space-2); + padding-top: var(--sf-space-2); + border-top: 1px solid var(--sf-border); + font-weight: 600; + font-size: 1.1rem; +} + +.sf-totals-grand { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: var(--sf-space-2); +} + +.sf-discount-block { + margin-bottom: var(--sf-space-3); + font-size: 0.95rem; +} + +.sf-discount-item { + margin-bottom: var(--sf-space-1); + font-size: 0.9rem; + color: var(--sf-text-muted); +} + +.sf-discount-error { + margin: 0 0 var(--sf-space-2); + font-size: 0.85rem; + color: var(--sf-error); + line-height: 1.35; +} + +.sf-product-grid { + display: grid; + gap: var(--sf-space-3); + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +@media (min-width: 36rem) { + .sf-product-grid { + grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--sf-product-card-min)), 1fr)); + } +} + +.sf-product-card { + width: 100%; + max-width: var(--sf-product-card-max); + min-width: 0; + justify-self: start; +} + +.sf-product-card__media-link { + display: block; +} + +.sf-product-card__image { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + border-radius: var(--sf-radius-sm); +} + +.sf-product-card__variants { + position: relative; + width: 100%; +} + +.sf-product-card__variant-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--sf-space-1); + width: 100%; + align-items: start; +} + +.sf-product-card__more { + position: absolute; + top: 50%; + right: 0; + transform: translate(75%, -50%); + z-index: 1; +} + +.sf-product-card__title { + font-size: 0.875rem; + line-height: 1.35; +} + +.sf-product-card__title a { + color: inherit; + text-decoration: none; +} + +.sf-product-card__title a:hover { + color: var(--sf-link-hover); + text-decoration: underline; +} + +.sf-product-card__price { + font-size: 0.875rem; + font-weight: 600; +} + +.sf-product-card__form { + margin-top: auto; + display: flex; + flex-direction: column; + gap: var(--sf-space-3); +} + +.sf-product-card__form .sf-btn { + min-height: 2rem; + padding: 0.35rem 0.75rem; + font-size: 0.85rem; +} + +.sf-product-card .sf-text-subtle, +.sf-product-card .sf-text-muted { + font-size: 0.8rem; + margin: 0; +} + +.sf-variant-picker { + display: flex; + flex-wrap: wrap; + gap: var(--sf-space-2); +} + +.sf-variant-section { + margin-bottom: var(--sf-space-4); +} + +.sf-variant-section__label { + margin: 0 0 var(--sf-space-2); + font-size: 0.9rem; + font-weight: 600; +} + +.sf-variant-link { + display: block; + box-sizing: border-box; + border: 2px solid var(--sf-border-light); + border-radius: var(--sf-radius); + text-decoration: none; + overflow: hidden; + width: 100%; + height: auto; + aspect-ratio: 1; +} + +.sf-variant-link--sm { + width: 3rem; + height: 3rem; + aspect-ratio: auto; +} + +.sf-variant-link--md { + width: 3.5rem; + height: 3.5rem; + aspect-ratio: auto; +} + +.sf-variant-link--selected { + border-color: var(--sf-accent); +} + +.sf-variant-link img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.sf-product-gallery { + max-width: 22.5rem; + margin-bottom: 0; +} + +.sf-product-detail { + display: grid; + grid-template-columns: 1fr minmax(260px, 360px); + grid-template-areas: + 'media buy' + 'description buy'; + gap: var(--sf-space-6); + align-items: start; +} + +.sf-product-detail__media { + grid-area: media; + min-width: 0; +} + +.sf-product-detail__media .sf-product-gallery { + max-width: min(100%, 28rem); +} + +.sf-product-detail__buy { + grid-area: buy; + display: flex; + flex-direction: column; + gap: var(--sf-space-3); +} + +.sf-product-detail__buy .sf-variant-section { + margin-bottom: 0; +} + +.sf-product-detail__buy .sf-text-subtle, +.sf-product-detail__buy .sf-text-muted { + margin: 0; +} + +.sf-product-detail__price { + margin: 0; + font-size: 1.25rem; + font-weight: 600; +} + +.sf-product-detail__description { + grid-area: description; + min-width: 0; +} + +@media (max-width: 768px) { + .sf-product-detail { + grid-template-columns: 1fr; + grid-template-areas: + 'media' + 'buy' + 'description'; + } +} + +.sf-product-gallery__main { + display: block; + width: 100%; + aspect-ratio: 1; + object-fit: cover; + border-radius: var(--sf-radius); + border: 1px solid var(--sf-border); +} + +.sf-product-gallery__thumbs { + display: flex; + flex-wrap: wrap; + gap: var(--sf-space-2); + margin-top: var(--sf-space-2); +} + +.sf-prose { + line-height: 1.6; + color: var(--sf-text); +} + +.sf-prose img { + border-radius: var(--sf-radius-sm); +} + +.sf-prose p { + margin: 0 0 var(--sf-space-3); +} + +.sf-payment-status { + margin: 0 0 var(--sf-space-3); + font-size: 1.1rem; + font-weight: 600; + line-height: 1.45; +} + +.sf-payment-status--confirmed { + color: var(--sf-success); +} + +.sf-payment-status--awaiting-confirmations { + color: var(--sf-warning); +} + +.sf-payment-status--expired { + color: var(--sf-error); +} + +.sf-payment-status--underpaid { + color: var(--sf-warning); +} + +.sf-payment-status--awaiting-payment { + color: var(--sf-text-muted); +} + +.sf-payment-amount { + margin: 0 0 var(--sf-space-3); + font-size: 1.35rem; + font-weight: 600; +} + +.sf-payment-detail { + margin: 0 0 var(--sf-space-2); + font-size: 0.95rem; + color: var(--sf-text-muted); + line-height: 1.45; +} + +.sf-payment-detail:last-child { + margin-bottom: var(--sf-space-4); +} + +.sf-qr-wrap { + text-align: center; + margin-bottom: var(--sf-space-4); +} + +.sf-tx-list { + display: flex; + flex-direction: column; + gap: var(--sf-space-3); +} + +.sf-tx-item { + border: 1px solid var(--sf-border); + border-radius: var(--sf-radius-sm); + padding: 0.625rem; + font-size: 0.9rem; +} + +.sf-tx-status { + font-size: 0.85rem; +} + +.sf-tx-status--confirmed { + color: var(--sf-success); +} + +.sf-tx-status--confirming { + color: var(--sf-warning); +} + +.sf-refresh-note { + margin: var(--sf-space-4) 0 0; + font-size: 0.85rem; + color: var(--sf-text-muted); + line-height: 1.45; +} + +.sf-refresh-link { + font-size: 0.9rem; +} + +.sf-order-status { + font-weight: 600; + text-transform: capitalize; +} + +.sf-order-status--fulfilled { + color: var(--sf-success); +} + +.sf-order-status--unfulfilled { + color: var(--sf-warning); +} + +.sf-order-status--unfulfillable { + color: var(--sf-error); +} + +.sf-order-main { + display: flex; + flex-direction: column; + gap: var(--sf-space-8); + min-width: 0; +} + +.sf-data-retention-notice { + margin: 0; + font-size: 0.95rem; + line-height: 1.45; +} + +.sf-token-box { + margin-bottom: var(--sf-space-5); +} + +.sf-delivery-box { + margin-top: var(--sf-space-3); + padding: var(--sf-space-3); + border: 1px solid var(--sf-border); + border-radius: var(--sf-radius-sm); + background: var(--sf-surface-muted); +} + +.sf-delivery-list { + display: flex; + flex-direction: column; + gap: var(--sf-space-3); + counter-reset: sf-delivery; +} + +.sf-delivery-item { + counter-increment: sf-delivery; + padding: var(--sf-space-3); + border: 1px solid var(--sf-border-light); + border-radius: var(--sf-radius); + background: var(--sf-surface); +} + +.sf-delivery-item::before { + content: counter(sf-delivery) '.'; + display: block; + margin-bottom: var(--sf-space-2); + font-size: 0.9rem; + font-weight: 600; + color: var(--sf-text-muted); +} + +.sf-delivery-item__content { + margin: 0; + font-size: 0.85rem; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.45; +} + +.sf-delivery-item .sf-attachment-list { + margin-top: var(--sf-space-2); + padding-top: var(--sf-space-2); + border-top: 1px solid var(--sf-border-light); +} + +.sf-attachment-list { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: var(--sf-space-2) var(--sf-space-3); +} + +.sf-chat-panel { + width: 100%; + min-width: 0; +} + +.sf-chat-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--sf-space-3); + margin-bottom: var(--sf-space-3); +} + +.sf-chat-thread { + display: flex; + flex-direction: column-reverse; + gap: var(--sf-space-4); + max-height: 31.25rem; + margin: 0 0 var(--sf-space-5); + overflow-y: auto; + overflow-x: hidden; + padding-right: var(--sf-space-1); +} + +.sf-chat-message { + display: flex; + flex-direction: column; + gap: var(--sf-space-2); + max-width: 100%; +} + +.sf-chat-message--buyer { + align-self: flex-end; + align-items: flex-end; +} + +.sf-chat-message--staff { + align-self: flex-start; + align-items: flex-start; +} + +.sf-chat-bubble { + position: relative; + border-radius: 0.875rem; + padding: var(--sf-space-3); + line-height: 1.45; + border: 1px solid var(--sf-border); + background: var(--sf-surface-muted); +} + +.sf-chat-message--buyer .sf-chat-bubble { + padding-right: 1.75rem; + background: var(--sf-accent-subtle); + border-color: var(--sf-accent-border); +} + +.sf-chat-bubble p { + margin: 0; + white-space: pre-wrap; +} + +.sf-chat-meta { + margin-top: var(--sf-space-2); + font-size: 0.8rem; + color: var(--sf-text-muted); +} + +.sf-chat-delete { + position: absolute; + top: 0.375rem; + right: 0.375rem; + margin: 0; + padding: 0 0.25rem; + border: none; + background: transparent; + color: var(--sf-error); + font: inherit; + font-size: 0.95rem; + line-height: 1; + cursor: pointer; +} + +.sf-chat-delete:hover { + color: var(--sf-error); + opacity: 0.8; +} + +.sf-chat-compose { + display: flex; + flex-direction: column; + gap: 0.625rem; + border-top: 1px solid var(--sf-border); + padding-top: var(--sf-space-4); +} + +.sf-confirm-details { + margin-top: var(--sf-space-4); + font-size: 0.95rem; +} + +.sf-confirm-details summary { + cursor: pointer; + color: var(--sf-text-muted); +} + +.sf-confirm-details__body { + margin-top: var(--sf-space-3); + padding-top: var(--sf-space-3); + border-top: 1px solid var(--sf-border); + color: var(--sf-text-muted); + line-height: 1.45; +} + +.sf-confirm-details__body p { + margin: 0 0 var(--sf-space-3); +} + +.sf-confirm-details--warning .sf-btn { + border-color: var(--sf-warning-border); +} + +.sf-error-page { + margin: var(--sf-space-6); +} + +.sf-mt-4 { + margin-top: var(--sf-space-4); +} + +.sf-error-code { + font-size: 2rem; + margin: 0 0 var(--sf-space-2); +} diff --git a/backend/src/modules/storefrontCore/services/StorefrontCaptchaCookieService.spec.ts b/backend/src/modules/storefrontCore/services/StorefrontCaptchaCookieService.spec.ts new file mode 100644 index 0000000..3578df2 --- /dev/null +++ b/backend/src/modules/storefrontCore/services/StorefrontCaptchaCookieService.spec.ts @@ -0,0 +1,30 @@ +import type { StorefrontSignedCookieService } from './StorefrontSignedCookieService'; +import { StorefrontCaptchaCookieService } from './StorefrontCaptchaCookieService'; + +describe('StorefrontCaptchaCookieService', () => { + let signedCookies: jest.Mocked>; + let service: StorefrontCaptchaCookieService; + + beforeEach(() => { + signedCookies = { + getSignedCookie: jest.fn(), + setSignedCookie: jest.fn() + }; + + service = new StorefrontCaptchaCookieService(signedCookies as unknown as StorefrontSignedCookieService); + }); + + it('returns the captcha answer when present', () => { + signedCookies.getSignedCookie.mockReturnValue({ answer: 'encrypted-answer' }); + + expect(service.getAnswer({} as never, {} as never)).toBe('encrypted-answer'); + }); + + it('can consume the captcha cookie when reading the answer', () => { + service.getAnswer({} as never, {} as never, { consume: true }); + + expect(signedCookies.getSignedCookie).toHaveBeenCalledWith({} as never, {} as never, 'captcha', { + consume: true + }); + }); +}); diff --git a/backend/src/modules/storefrontCore/services/StorefrontCaptchaCookieService.ts b/backend/src/modules/storefrontCore/services/StorefrontCaptchaCookieService.ts new file mode 100644 index 0000000..41fbf77 --- /dev/null +++ b/backend/src/modules/storefrontCore/services/StorefrontCaptchaCookieService.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import type { StorefrontCaptchaCookiePayload } from '../types/StorefrontCaptchaCookiePayload'; +import { StorefrontSignedCookieService } from './StorefrontSignedCookieService'; + +@Injectable() +export class StorefrontCaptchaCookieService { + constructor(private readonly signedCookies: StorefrontSignedCookieService) {} + + getAnswer(req: Request, res: Response, { consume = false }: { consume?: boolean } = {}): string | undefined { + const payload = this.signedCookies.getSignedCookie(req, res, 'captcha', { + consume + }); + + return payload?.answer; + } + + setAnswer(req: Request, res: Response, encryptedAnswer: string): void { + this.signedCookies.setSignedCookie(req, res, 'captcha', { answer: encryptedAnswer }); + } +} diff --git a/backend/src/modules/storefrontCore/services/StorefrontCaptchaService.spec.ts b/backend/src/modules/storefrontCore/services/StorefrontCaptchaService.spec.ts new file mode 100644 index 0000000..79a1f63 --- /dev/null +++ b/backend/src/modules/storefrontCore/services/StorefrontCaptchaService.spec.ts @@ -0,0 +1,59 @@ +import { randomBytes } from 'node:crypto'; +import { ConfigService } from '@nestjs/config'; +import { EncryptionService } from '../../encryption/services/EncryptionService'; +import { StorefrontCaptchaService } from './StorefrontCaptchaService'; + +jest.mock('svg-captcha', () => ({ + create: jest.fn(() => ({ + data: 'captcha', + text: 'AbCdE' + })) +})); + +describe('StorefrontCaptchaService', () => { + const validKeyBase64 = randomBytes(32).toString('base64'); + let encryptionService: EncryptionService; + let service: StorefrontCaptchaService; + + beforeEach(() => { + encryptionService = new EncryptionService({ + get: jest.fn().mockReturnValue({ keyBase64: validKeyBase64 }) + } as unknown as ConfigService); + + service = new StorefrontCaptchaService( + { + get: jest.fn().mockReturnValue({ + captcha: { length: 5 } + }) + } as unknown as ConfigService, + encryptionService + ); + }); + + it('creates captcha svg and encrypted answer', () => { + const result = service.create(); + + expect(result.svg).toBe('captcha'); + expect(encryptionService.decryptPlaintext(result.encryptedAnswer)).toBe('abcde'); + }); + + it('verifies matching captcha answer', () => { + const encryptedAnswer = encryptionService.encryptPlaintext('abcde'); + + expect(service.verify('AbCdE', encryptedAnswer)).toBe(true); + }); + + it('rejects wrong captcha answer', () => { + const encryptedAnswer = encryptionService.encryptPlaintext('abcde'); + + expect(service.verify('wrong', encryptedAnswer)).toBe(false); + }); + + it('rejects missing encrypted answer', () => { + expect(service.verify('abcde', undefined)).toBe(false); + }); + + it('throws on invalid encrypted answer', () => { + expect(() => service.verify('abcde', 'not-valid-json')).toThrow(); + }); +}); diff --git a/backend/src/modules/storefrontCore/services/StorefrontCaptchaService.ts b/backend/src/modules/storefrontCore/services/StorefrontCaptchaService.ts new file mode 100644 index 0000000..f96b63a --- /dev/null +++ b/backend/src/modules/storefrontCore/services/StorefrontCaptchaService.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import svgCaptcha from 'svg-captcha'; +import type { Config } from '../../../types/Config'; +import { EncryptionService } from '../../encryption/services/EncryptionService'; + +@Injectable() +export class StorefrontCaptchaService { + constructor( + private readonly configService: ConfigService, + private readonly encryptionService: EncryptionService + ) {} + + create(): { svg: string; encryptedAnswer: string } { + const { captcha } = this.configService.get('app') as Config['app']; + + const { data, text } = svgCaptcha.create({ + size: captcha.length, + noise: 4, + ignoreChars: '0o1iIl', + color: true, + background: '#f5f5f5' + }); + + const normalizedAnswer = this.normalizeAnswer(text); + const encryptedAnswer = this.encryptionService.encryptPlaintext(normalizedAnswer); + + return { svg: data, encryptedAnswer }; + } + + verify(userInput: string, encryptedAnswer: string | undefined): boolean { + if (encryptedAnswer === undefined) { + return false; + } + + const expectedAnswer = this.encryptionService.decryptPlaintext(encryptedAnswer); + + return this.normalizeAnswer(userInput) === expectedAnswer; + } + + private normalizeAnswer(text: string): string { + return text.trim().toLowerCase(); + } +} diff --git a/backend/src/modules/storefrontCore/services/StorefrontCartCookieService.spec.ts b/backend/src/modules/storefrontCore/services/StorefrontCartCookieService.spec.ts new file mode 100644 index 0000000..d4b1e5c --- /dev/null +++ b/backend/src/modules/storefrontCore/services/StorefrontCartCookieService.spec.ts @@ -0,0 +1,35 @@ +import type { StorefrontSignedCookieService } from './StorefrontSignedCookieService'; +import { StorefrontCartCookieService } from './StorefrontCartCookieService'; + +describe('StorefrontCartCookieService', () => { + let signedCookies: jest.Mocked>; + let service: StorefrontCartCookieService; + + beforeEach(() => { + signedCookies = { + getSignedCookie: jest.fn(), + setSignedCookie: jest.fn(), + clearSignedCookieByProfile: jest.fn() + }; + + service = new StorefrontCartCookieService(signedCookies as unknown as StorefrontSignedCookieService); + }); + + it('returns an empty cart when the cookie is missing', () => { + expect(service.getCart({} as never, {} as never)).toEqual([]); + }); + + it('persists the cart in a signed cookie', () => { + const cart = [{ variantId: 'variant-1', qty: 2 }]; + + service.setCart({} as never, {} as never, cart); + + expect(signedCookies.setSignedCookie).toHaveBeenCalledWith({} as never, {} as never, 'cart', { cart }); + }); + + it('clears the cart cookie', () => { + service.clearCart({} as never, {} as never); + + expect(signedCookies.clearSignedCookieByProfile).toHaveBeenCalledWith({} as never, {} as never, 'cart'); + }); +}); diff --git a/backend/src/modules/storefrontCore/services/StorefrontCartCookieService.ts b/backend/src/modules/storefrontCore/services/StorefrontCartCookieService.ts new file mode 100644 index 0000000..2a52d72 --- /dev/null +++ b/backend/src/modules/storefrontCore/services/StorefrontCartCookieService.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import type { CookieCart } from '../types/cart/CookieCart'; +import { StorefrontSignedCookieService } from './StorefrontSignedCookieService'; + +@Injectable() +export class StorefrontCartCookieService { + constructor(private readonly signedCookies: StorefrontSignedCookieService) {} + + getCart(req: Request, res: Response): CookieCart { + const payload = this.signedCookies.getSignedCookie<{ cart: CookieCart }>(req, res, 'cart'); + + return payload?.cart ?? []; + } + + setCart(req: Request, res: Response, cart: CookieCart): void { + this.signedCookies.setSignedCookie(req, res, 'cart', { cart }); + } + + clearCart(req: Request, res: Response): void { + this.signedCookies.clearSignedCookieByProfile(req, res, 'cart'); + } +} diff --git a/backend/src/modules/storefrontCore/services/StorefrontCheckoutSessionCookieService.spec.ts b/backend/src/modules/storefrontCore/services/StorefrontCheckoutSessionCookieService.spec.ts new file mode 100644 index 0000000..9e94cd4 --- /dev/null +++ b/backend/src/modules/storefrontCore/services/StorefrontCheckoutSessionCookieService.spec.ts @@ -0,0 +1,29 @@ +import type { StorefrontSignedCookieService } from './StorefrontSignedCookieService'; +import { StorefrontCheckoutSessionCookieService } from './StorefrontCheckoutSessionCookieService'; + +describe('StorefrontCheckoutSessionCookieService', () => { + let signedCookies: jest.Mocked>; + let service: StorefrontCheckoutSessionCookieService; + + beforeEach(() => { + signedCookies = { + getSignedCookie: jest.fn(), + setSignedCookie: jest.fn(), + clearSignedCookieByProfile: jest.fn() + }; + + service = new StorefrontCheckoutSessionCookieService(signedCookies as unknown as StorefrontSignedCookieService); + }); + + it('returns undefined when no checkout session cookie exists', () => { + expect(service.getSessionId({} as never, {} as never)).toBeUndefined(); + }); + + it('stores the checkout session id in a signed cookie', () => { + service.setSessionId({} as never, {} as never, 'session-1'); + + expect(signedCookies.setSignedCookie).toHaveBeenCalledWith({} as never, {} as never, 'checkoutSession', { + sessionId: 'session-1' + }); + }); +}); diff --git a/backend/src/modules/storefrontCore/services/StorefrontCheckoutSessionCookieService.ts b/backend/src/modules/storefrontCore/services/StorefrontCheckoutSessionCookieService.ts new file mode 100644 index 0000000..28f57dc --- /dev/null +++ b/backend/src/modules/storefrontCore/services/StorefrontCheckoutSessionCookieService.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import type { CheckoutSessionCookiePayload } from '../types/CheckoutSessionCookiePayload'; +import { StorefrontSignedCookieService } from '../../storefrontCore/services/StorefrontSignedCookieService'; + +@Injectable() +export class StorefrontCheckoutSessionCookieService { + constructor(private readonly signedCookies: StorefrontSignedCookieService) {} + + getSessionId(req: Request, res: Response): string | undefined { + const payload = this.signedCookies.getSignedCookie(req, res, 'checkoutSession'); + + return payload?.sessionId; + } + + setSessionId(req: Request, res: Response, sessionId: string): void { + this.signedCookies.setSignedCookie(req, res, 'checkoutSession', { sessionId }); + } + + clearSession(req: Request, res: Response): void { + this.signedCookies.clearSignedCookieByProfile(req, res, 'checkoutSession'); + } +} diff --git a/backend/src/modules/storefrontCore/services/StorefrontDiscountCookieService.spec.ts b/backend/src/modules/storefrontCore/services/StorefrontDiscountCookieService.spec.ts new file mode 100644 index 0000000..3bc23a8 --- /dev/null +++ b/backend/src/modules/storefrontCore/services/StorefrontDiscountCookieService.spec.ts @@ -0,0 +1,31 @@ +import type { StorefrontSignedCookieService } from './StorefrontSignedCookieService'; +import { StorefrontDiscountCookieService } from './StorefrontDiscountCookieService'; + +describe('StorefrontDiscountCookieService', () => { + let signedCookies: jest.Mocked>; + let service: StorefrontDiscountCookieService; + + beforeEach(() => { + signedCookies = { + getSignedCookie: jest.fn(), + setSignedCookie: jest.fn(), + clearSignedCookieByProfile: jest.fn() + }; + + service = new StorefrontDiscountCookieService(signedCookies as unknown as StorefrontSignedCookieService); + }); + + it('returns an empty list when the cookie is missing or invalid', () => { + expect(service.getDiscountCodes({} as never, {} as never)).toEqual([]); + signedCookies.getSignedCookie.mockReturnValue({ codes: 'SAVE10' } as never); + expect(service.getDiscountCodes({} as never, {} as never)).toEqual([]); + }); + + it('stores discount codes in a signed cookie', () => { + service.setDiscountCodes({} as never, {} as never, ['SAVE10']); + + expect(signedCookies.setSignedCookie).toHaveBeenCalledWith({} as never, {} as never, 'discount', { + codes: ['SAVE10'] + }); + }); +}); diff --git a/backend/src/modules/storefrontCore/services/StorefrontDiscountCookieService.ts b/backend/src/modules/storefrontCore/services/StorefrontDiscountCookieService.ts new file mode 100644 index 0000000..7e6b986 --- /dev/null +++ b/backend/src/modules/storefrontCore/services/StorefrontDiscountCookieService.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { StorefrontSignedCookieService } from './StorefrontSignedCookieService'; + +@Injectable() +export class StorefrontDiscountCookieService { + constructor(private readonly signedCookies: StorefrontSignedCookieService) {} + + getDiscountCodes(req: Request, res: Response): string[] { + const payload = this.signedCookies.getSignedCookie<{ codes: string[] }>(req, res, 'discount'); + + if (!payload?.codes || !Array.isArray(payload.codes)) { + return []; + } + + return payload.codes; + } + + setDiscountCodes(req: Request, res: Response, codes: string[]): void { + this.signedCookies.setSignedCookie(req, res, 'discount', { codes }); + } + + clearDiscount(req: Request, res: Response): void { + this.signedCookies.clearSignedCookieByProfile(req, res, 'discount'); + } +} diff --git a/backend/src/modules/storefrontCore/services/StorefrontErrorCookieService.spec.ts b/backend/src/modules/storefrontCore/services/StorefrontErrorCookieService.spec.ts new file mode 100644 index 0000000..3193822 --- /dev/null +++ b/backend/src/modules/storefrontCore/services/StorefrontErrorCookieService.spec.ts @@ -0,0 +1,32 @@ +import { HttpStatus } from '@nestjs/common'; +import type { StorefrontSignedCookieService } from './StorefrontSignedCookieService'; +import { StorefrontErrorCookieService } from './StorefrontErrorCookieService'; + +describe('StorefrontErrorCookieService', () => { + let signedCookies: jest.Mocked>; + let service: StorefrontErrorCookieService; + + beforeEach(() => { + signedCookies = { + getSignedCookie: jest.fn(), + setSignedCookie: jest.fn() + }; + + service = new StorefrontErrorCookieService(signedCookies as unknown as StorefrontSignedCookieService); + }); + + it('returns a default error payload when the cookie is missing', () => { + expect(service.getError({} as never, {} as never)).toEqual({ + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + message: 'Something went wrong.' + }); + }); + + it('consumes the error cookie when reading it', () => { + service.getError({} as never, {} as never); + + expect(signedCookies.getSignedCookie).toHaveBeenCalledWith({} as never, {} as never, 'error', { + consume: true + }); + }); +}); diff --git a/backend/src/modules/storefrontCore/services/StorefrontErrorCookieService.ts b/backend/src/modules/storefrontCore/services/StorefrontErrorCookieService.ts new file mode 100644 index 0000000..b6b34b3 --- /dev/null +++ b/backend/src/modules/storefrontCore/services/StorefrontErrorCookieService.ts @@ -0,0 +1,26 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import type { StorefrontErrorPayload } from '../types/StorefrontErrorPayload'; +import { StorefrontSignedCookieService } from './StorefrontSignedCookieService'; + +@Injectable() +export class StorefrontErrorCookieService { + constructor(private readonly signedCookies: StorefrontSignedCookieService) {} + + getError(req: Request, res: Response): StorefrontErrorPayload { + const fromCookie = this.signedCookies.getSignedCookie(req, res, 'error', { + consume: true + }); + + return ( + fromCookie ?? { + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + message: 'Something went wrong.' + } + ); + } + + setError(req: Request, res: Response, payload: StorefrontErrorPayload): void { + this.signedCookies.setSignedCookie(req, res, 'error', payload); + } +} diff --git a/backend/src/modules/storefrontCore/services/StorefrontFeedbackCookieService.spec.ts b/backend/src/modules/storefrontCore/services/StorefrontFeedbackCookieService.spec.ts new file mode 100644 index 0000000..28ba218 --- /dev/null +++ b/backend/src/modules/storefrontCore/services/StorefrontFeedbackCookieService.spec.ts @@ -0,0 +1,33 @@ +import type { StorefrontSignedCookieService } from './StorefrontSignedCookieService'; +import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService'; + +describe('StorefrontFeedbackCookieService', () => { + let signedCookies: jest.Mocked>; + let service: StorefrontFeedbackCookieService; + + beforeEach(() => { + signedCookies = { + getSignedCookie: jest.fn(), + setSignedCookie: jest.fn() + }; + + service = new StorefrontFeedbackCookieService(signedCookies as unknown as StorefrontSignedCookieService); + }); + + it('reads feedback with consume enabled', () => { + service.getFeedback({} as never, {} as never); + + expect(signedCookies.getSignedCookie).toHaveBeenCalledWith({} as never, {} as never, 'feedback', { + consume: true + }); + }); + + it('stores feedback in a signed cookie', () => { + service.setFeedback({} as never, {} as never, { type: 'success', text: 'Saved' }); + + expect(signedCookies.setSignedCookie).toHaveBeenCalledWith({} as never, {} as never, 'feedback', { + type: 'success', + text: 'Saved' + }); + }); +}); diff --git a/backend/src/modules/storefrontCore/services/StorefrontFeedbackCookieService.ts b/backend/src/modules/storefrontCore/services/StorefrontFeedbackCookieService.ts new file mode 100644 index 0000000..e1dbab6 --- /dev/null +++ b/backend/src/modules/storefrontCore/services/StorefrontFeedbackCookieService.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import type { StorefrontFeedback } from '../types/StorefrontFeedback'; +import { StorefrontSignedCookieService } from './StorefrontSignedCookieService'; + +@Injectable() +export class StorefrontFeedbackCookieService { + constructor(private readonly signedCookies: StorefrontSignedCookieService) {} + + getFeedback(req: Request, res: Response): StorefrontFeedback | undefined { + return this.signedCookies.getSignedCookie(req, res, 'feedback', { + consume: true + }); + } + + setFeedback(req: Request, res: Response, { type, text }: StorefrontFeedback): void { + this.signedCookies.setSignedCookie(req, res, 'feedback', { type, text }); + } +} diff --git a/backend/src/modules/storefrontCore/services/StorefrontOrderAuthCookieService.spec.ts b/backend/src/modules/storefrontCore/services/StorefrontOrderAuthCookieService.spec.ts new file mode 100644 index 0000000..fbf7191 --- /dev/null +++ b/backend/src/modules/storefrontCore/services/StorefrontOrderAuthCookieService.spec.ts @@ -0,0 +1,109 @@ +import type { StorefrontSignedCookieService } from './StorefrontSignedCookieService'; +import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService'; + +describe('StorefrontOrderAuthCookieService', () => { + let signedCookies: jest.Mocked< + Pick + >; + let service: StorefrontOrderAuthCookieService; + + beforeEach(() => { + signedCookies = { + getSignedCookie: jest.fn(), + setSignedCookie: jest.fn(), + clearSignedCookieByProfile: jest.fn() + }; + + service = new StorefrontOrderAuthCookieService(signedCookies as unknown as StorefrontSignedCookieService); + }); + + it('grants access by setting a cookie with the order id', () => { + const req = {} as never; + const res = {} as never; + + service.grantAccess(req, res, 'order-1'); + + expect(signedCookies.setSignedCookie).toHaveBeenCalledWith(req, res, 'orderAuth', { + orderIds: ['order-1'] + }); + }); + + it('merges new order ids into an existing cookie payload', () => { + signedCookies.getSignedCookie.mockReturnValue({ orderIds: ['order-1'] }); + + const req = {} as never; + const res = {} as never; + + service.grantAccess(req, res, 'order-2'); + + expect(signedCookies.setSignedCookie).toHaveBeenCalledWith(req, res, 'orderAuth', { + orderIds: ['order-1', 'order-2'] + }); + }); + + it('moves a re-authenticated order id to the end of the list', () => { + signedCookies.getSignedCookie.mockReturnValue({ orderIds: ['order-1', 'order-2'] }); + + const req = {} as never; + const res = {} as never; + + service.grantAccess(req, res, 'order-1'); + + expect(signedCookies.setSignedCookie).toHaveBeenCalledWith(req, res, 'orderAuth', { + orderIds: ['order-2', 'order-1'] + }); + }); + + it('returns true when the cookie includes the order id', () => { + signedCookies.getSignedCookie.mockReturnValue({ orderIds: ['order-1', 'order-2'] }); + + expect(service.isAuthorized({} as never, {} as never, 'order-2')).toBe(true); + }); + + it('returns false when the cookie is missing or does not include the order id', () => { + signedCookies.getSignedCookie.mockReturnValue(undefined); + + expect(service.isAuthorized({} as never, {} as never, 'order-2')).toBe(false); + + signedCookies.getSignedCookie.mockReturnValue({ orderIds: ['order-1'] }); + + expect(service.isAuthorized({} as never, {} as never, 'order-2')).toBe(false); + }); + + it('returns authorized order ids from the cookie', () => { + signedCookies.getSignedCookie.mockReturnValue({ orderIds: ['order-1', 'order-2'] }); + + expect(service.getAuthorizedOrderIds({} as never, {} as never)).toEqual(['order-1', 'order-2']); + }); + + it('returns an empty list when the cookie is missing', () => { + signedCookies.getSignedCookie.mockReturnValue(undefined); + + expect(service.getAuthorizedOrderIds({} as never, {} as never)).toEqual([]); + }); + + it('removes an order id from the cookie', () => { + signedCookies.getSignedCookie.mockReturnValue({ orderIds: ['order-1', 'order-2'] }); + + const req = {} as never; + const res = {} as never; + + service.revokeAccess(req, res, 'order-1'); + + expect(signedCookies.setSignedCookie).toHaveBeenCalledWith(req, res, 'orderAuth', { + orderIds: ['order-2'] + }); + }); + + it('clears the cookie when revoking the last authorized order', () => { + signedCookies.getSignedCookie.mockReturnValue({ orderIds: ['order-1'] }); + + const req = {} as never; + const res = {} as never; + + service.revokeAccess(req, res, 'order-1'); + + expect(signedCookies.clearSignedCookieByProfile).toHaveBeenCalledWith(req, res, 'orderAuth'); + expect(signedCookies.setSignedCookie).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/modules/storefrontCore/services/StorefrontOrderAuthCookieService.ts b/backend/src/modules/storefrontCore/services/StorefrontOrderAuthCookieService.ts new file mode 100644 index 0000000..3a07bd9 --- /dev/null +++ b/backend/src/modules/storefrontCore/services/StorefrontOrderAuthCookieService.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import type { OrderAuthCookiePayload } from '../types/OrderAuthCookiePayload'; +import { StorefrontSignedCookieService } from './StorefrontSignedCookieService'; + +@Injectable() +export class StorefrontOrderAuthCookieService { + private readonly maxAuthorizedOrders = 10; + + constructor(private readonly signedCookies: StorefrontSignedCookieService) {} + + grantAccess(req: Request, res: Response, orderId: string): void { + const payload = this.signedCookies.getSignedCookie(req, res, 'orderAuth'); + const orderIds = this.mergeNewOrderId(payload?.orderIds, orderId); + + this.signedCookies.setSignedCookie(req, res, 'orderAuth', { orderIds }); + } + + getAuthorizedOrderIds(req: Request, res: Response): string[] { + const payload = this.signedCookies.getSignedCookie(req, res, 'orderAuth'); + + return payload?.orderIds ?? []; + } + + isAuthorized(req: Request, res: Response, orderId: string): boolean { + return this.getAuthorizedOrderIds(req, res).includes(orderId); + } + + revokeAccess(req: Request, res: Response, orderId: string): void { + const payload = this.signedCookies.getSignedCookie(req, res, 'orderAuth'); + const newOrderIds = (payload?.orderIds ?? []).filter(id => id !== orderId); + + if (newOrderIds.length === 0) { + this.signedCookies.clearSignedCookieByProfile(req, res, 'orderAuth'); + + return; + } + + this.signedCookies.setSignedCookie(req, res, 'orderAuth', { orderIds: newOrderIds }); + } + + private mergeNewOrderId(existingOrderIds: string[] | undefined, newOrderId: string): string[] { + const withoutDuplicate = (existingOrderIds ?? []).filter(id => id !== newOrderId); + + const merged = [...withoutDuplicate, newOrderId]; + + if (merged.length <= this.maxAuthorizedOrders) { + return merged; + } + + return merged.slice(merged.length - this.maxAuthorizedOrders); + } +} diff --git a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts new file mode 100644 index 0000000..d3aa91b --- /dev/null +++ b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts @@ -0,0 +1,312 @@ +import type { Request, Response } from 'express'; +import { ConfigService } from '@nestjs/config'; +import { StorefrontShopViewService } from './StorefrontShopViewService'; +import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService'; +import { XmrRateService } from '../../xmrRate/services/XmrRateService'; +import { StorefrontCartCookieService } from './StorefrontCartCookieService'; +import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService'; +import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService'; +import { StorefrontThemeCookieService } from './StorefrontThemeCookieService'; +import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput'; + +describe('StorefrontShopViewService', () => { + type ServiceOverrides = { + cart?: { variantId: string; qty: number }[]; + feedback?: { type: 'success'; text: string }; + authorizedOrderIds?: string[]; + theme?: 'light' | 'dark'; + branding?: { + logoUrl: string | null; + faviconUrl: string | null; + simplexLink: string | null; + shippingNote: string | null; + }; + shopSettings?: { shopName: string; shopFiatCurrency: string }; + fiatPerXmr?: number; + req?: Partial> & { + host?: string; + }; + }; + + const defaultPage: StorefrontPageMetaInput = { + title: 'Cart', + metaDescription: 'Review your cart.' + }; + + const createService = (overrides: ServiceOverrides = {}) => { + const { + cart = [], + feedback, + authorizedOrderIds = [], + theme, + branding = { + logoUrl: null, + faviconUrl: null, + simplexLink: null, + shippingNote: null + }, + shopSettings = { shopName: 'Demo Shop', shopFiatCurrency: 'USD' }, + fiatPerXmr = 150, + req: reqOverrides = {} + } = overrides; + + const xmrRateService = { + getLiveFiatPerXmr: jest.fn().mockReturnValue(fiatPerXmr) + } as unknown as XmrRateService; + const configService = { + get: jest.fn().mockReturnValue(shopSettings) + } as unknown as ConfigService; + const shopSettingsService = { + getStorefrontBranding: jest.fn().mockResolvedValue(branding) + } as unknown as ShopSettingsService; + const cartCookieService = { + getCart: jest.fn().mockReturnValue(cart) + } as unknown as StorefrontCartCookieService; + const feedbackCookieService = { + getFeedback: jest.fn().mockReturnValue(feedback) + } as unknown as StorefrontFeedbackCookieService; + const orderAuthCookieService = { + getAuthorizedOrderIds: jest.fn().mockReturnValue(authorizedOrderIds) + } as unknown as StorefrontOrderAuthCookieService; + const themeCookieService = { + getTheme: jest.fn().mockReturnValue(theme) + } as unknown as StorefrontThemeCookieService; + + const service = new StorefrontShopViewService( + xmrRateService, + configService, + shopSettingsService, + cartCookieService, + feedbackCookieService, + orderAuthCookieService, + themeCookieService + ); + + const req = { + protocol: reqOverrides.protocol ?? 'https', + get: jest.fn().mockReturnValue(reqOverrides.host ?? 'shop.example'), + path: reqOverrides.path ?? '/shop/cart', + originalUrl: reqOverrides.originalUrl ?? reqOverrides.path ?? '/shop/cart' + } as unknown as Request; + const res = {} as Response; + + return { service, req, res }; + }; + + it('builds page meta with canonical and full title', async () => { + const { service, req, res } = createService(); + + const locals = await service.buildShopRenderLocals(req, res, defaultPage); + + expect(locals.title).toBe('Cart'); + expect(locals.pageMeta).toEqual({ + title: 'Cart', + documentTitle: 'Cart - Demo Shop', + metaDescription: 'Review your cart.', + canonicalUrl: 'https://shop.example/shop/cart', + ogType: 'website', + ogImageUrl: null, + productJsonLd: null + }); + }); + + it('exposes shop settings, cart qty, feedback, branding, theme, and request context', async () => { + const { service, req, res } = createService({ + cart: [ + { variantId: 'a', qty: 2 }, + { variantId: 'b', qty: 1 } + ], + feedback: { type: 'success', text: 'Added to cart' }, + theme: 'dark', + branding: { + logoUrl: '/uploads/logo.png', + faviconUrl: '/uploads/favicon.ico', + simplexLink: 'https://simplex.example', + shippingNote: 'Ships in 3 days' + }, + fiatPerXmr: 200 + }); + + const locals = await service.buildShopRenderLocals(req, res, defaultPage); + + expect(locals).toMatchObject({ + shopName: 'Demo Shop', + cartTotalQty: 3, + feedback: { type: 'success', text: 'Added to cart' }, + shopFiatCurrency: 'USD', + fiatPerXmr: 200, + logoUrl: '/uploads/logo.png', + faviconUrl: '/uploads/favicon.ico', + simplexLink: 'https://simplex.example', + shippingNote: 'Ships in 3 days', + themePreference: 'dark' + }); + }); + + it('builds authorized order nav items from cookie', async () => { + const orderId = '12345678-abcd-ef01-2345-6789abcdef01'; + const { service, req, res } = createService({ authorizedOrderIds: [orderId] }); + + const locals = await service.buildShopRenderLocals(req, res, defaultPage); + + expect(locals.authorizedOrders).toEqual([ + { + orderId, + label: 'Order #1234', + href: `/shop/order/${orderId}`, + logoutHref: `/shop/order/${orderId}/logout` + } + ]); + }); + + it('resolves shop nav active state from request path', async () => { + const { service: homeService, req: homeReq, res } = createService({ + req: { path: '/', originalUrl: '/' } + }); + const homeLocals = await homeService.buildShopRenderLocals(homeReq, res, { + title: 'Shop', + metaDescription: 'Browse {shopName}.' + }); + + const { service: orderService, req: orderReq } = createService({ + req: { + path: '/shop/order/12345678-abcd-ef01-2345-6789abcdef01', + originalUrl: '/shop/order/12345678-abcd-ef01-2345-6789abcdef01' + } + }); + const orderLocals = await orderService.buildShopRenderLocals(orderReq, res, { + title: 'Your order', + metaDescription: 'View your order at {shopName}.' + }); + + expect(homeLocals.shopNavActive).toEqual({ activeShopNav: 'shop', activeOrderId: null }); + expect(orderLocals.shopNavActive).toEqual({ + activeShopNav: null, + activeOrderId: '12345678-abcd-ef01-2345-6789abcdef01' + }); + }); + + it('builds canonical url for shop home', async () => { + const { service, req, res } = createService({ + req: { path: '/', originalUrl: '/' } + }); + + const locals = await service.buildShopRenderLocals(req, res, { + title: 'Shop', + metaDescription: 'Browse {shopName}.' + }); + + expect(locals.pageMeta.canonicalUrl).toBe('https://shop.example/'); + }); + + it('strips query string from canonical url and current path', async () => { + const { service, req, res } = createService({ + req: { + path: '/shop/products/1/variants/2', + originalUrl: '/shop/products/1/variants/2?category=3&selectedImage=4' + } + }); + + const locals = await service.buildShopRenderLocals(req, res, defaultPage); + + expect(locals.pageMeta.canonicalUrl).toBe('https://shop.example/shop/products/1/variants/2'); + }); + + it('resolves relative og image urls, shop name placeholders, and product json-ld', async () => { + const { service, res } = createService(); + const req = { + protocol: 'https', + get: jest.fn().mockReturnValue('shop.example'), + path: '/shop/products/1/variants/2', + originalUrl: '/shop/products/1/variants/2?category=3' + } as unknown as Request; + + const locals = await service.buildShopRenderLocals(req, res, { + title: 'Widget', + metaDescription: 'Buy at {shopName}.', + ogType: 'product', + ogImageUrl: '/uploads/public/thumb.jpg', + product: { + imageUrl: '/uploads/public/thumb.jpg', + price: 9.99, + inStock: true + } + }); + + expect(locals.pageMeta.metaDescription).toBe('Buy at Demo Shop.'); + expect(locals.pageMeta.ogImageUrl).toBe('https://shop.example/uploads/public/thumb.jpg'); + expect(JSON.parse(locals.pageMeta.productJsonLd!)).toEqual({ + '@context': 'https://schema.org', + '@type': 'Product', + name: 'Widget', + url: 'https://shop.example/shop/products/1/variants/2', + image: 'https://shop.example/uploads/public/thumb.jpg', + offers: { + '@type': 'Offer', + price: '9.99', + priceCurrency: 'USD', + availability: 'https://schema.org/InStock', + url: 'https://shop.example/shop/products/1/variants/2' + } + }); + }); + + it('passes absolute og image urls through unchanged', async () => { + const { service, req, res } = createService(); + + const locals = await service.buildShopRenderLocals(req, res, { + ...defaultPage, + ogImageUrl: 'https://cdn.example/product.jpg' + }); + + expect(locals.pageMeta.ogImageUrl).toBe('https://cdn.example/product.jpg'); + }); + + it('uses shop fiat currency in product json-ld', async () => { + const { service, req, res } = createService({ + shopSettings: { shopName: 'Euro Shop', shopFiatCurrency: 'EUR' }, + req: { + path: '/shop/products/1/variants/2', + originalUrl: '/shop/products/1/variants/2' + } + }); + + const locals = await service.buildShopRenderLocals(req, res, { + title: 'Widget', + metaDescription: 'Buy at {shopName}.', + product: { imageUrl: null, price: 5, inStock: true } + }); + + const parsed = JSON.parse(locals.pageMeta.productJsonLd!) as { + offers: { priceCurrency: string }; + }; + + expect(parsed.offers.priceCurrency).toBe('EUR'); + expect(locals.pageMeta.documentTitle).toBe('Widget - Euro Shop'); + }); + + it('omits product image and marks out of stock in json-ld', async () => { + const { service, res } = createService(); + const req = { + protocol: 'https', + get: jest.fn().mockReturnValue('shop.example'), + path: '/shop/products/1/variants/2', + originalUrl: '/shop/products/1/variants/2' + } as unknown as Request; + + const locals = await service.buildShopRenderLocals(req, res, { + title: 'Widget', + metaDescription: 'Buy at {shopName}.', + product: { + imageUrl: null, + price: 10, + inStock: false + } + }); + + const parsed = JSON.parse(locals.pageMeta.productJsonLd!) as Record; + + expect(parsed.image).toBeUndefined(); + expect((parsed.offers as { availability: string }).availability).toBe('https://schema.org/OutOfStock'); + }); +}); diff --git a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts new file mode 100644 index 0000000..33e4bd9 --- /dev/null +++ b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts @@ -0,0 +1,132 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { Request, Response } from 'express'; +import type { Config } from '../../../types/Config'; +import type { ShopFiatCurrency } from '../../../types/ShopFiatCurrency'; +import { getTotalCartQtyFromCart } from '../../../utils/cart/getTotalCartQtyFromCart'; +import { formatShortOrderId } from '../../../utils/order/formatShortOrderId'; +import { toAbsoluteUrl } from '../../../utils/toAbsoluteUrl'; +import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService'; +import { XmrRateService } from '../../xmrRate/services/XmrRateService'; +import type { AuthorizedOrderNavItem } from '../types/AuthorizedOrderNavItem'; +import type { ShopRenderLocals } from '../types/ShopRenderLocals'; +import type { StorefrontPageMeta } from '../types/StorefrontPageMeta'; +import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput'; +import type { StorefrontProductJsonLdInput } from '../types/StorefrontProductJsonLdInput'; +import { resolveShopNavActive } from '../utils/resolveShopNavActive'; +import { StorefrontCartCookieService } from './StorefrontCartCookieService'; +import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService'; +import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService'; +import { StorefrontThemeCookieService } from './StorefrontThemeCookieService'; + +@Injectable() +export class StorefrontShopViewService { + constructor( + private readonly xmrRateService: XmrRateService, + private readonly configService: ConfigService, + private readonly shopSettingsService: ShopSettingsService, + private readonly cartCookieService: StorefrontCartCookieService, + private readonly feedbackCookieService: StorefrontFeedbackCookieService, + private readonly orderAuthCookieService: StorefrontOrderAuthCookieService, + private readonly themeCookieService: StorefrontThemeCookieService + ) {} + + async buildShopRenderLocals(req: Request, res: Response, page: StorefrontPageMetaInput): Promise { + const cart = this.cartCookieService.getCart(req, res); + const feedback = this.feedbackCookieService.getFeedback(req, res); + const themePreference = this.themeCookieService.getTheme(req, res); + + const cartTotalQty = getTotalCartQtyFromCart(cart); + + const { shopName, shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings']; + + const fiatPerXmr = this.xmrRateService.getLiveFiatPerXmr(); + + const { logoUrl, faviconUrl, simplexLink, shippingNote } = + await this.shopSettingsService.getStorefrontBranding(); + + const authorizedOrders = this.buildAuthorizedOrderNavItems(req, res); + + const shopNavActive = resolveShopNavActive(req.path); + + const siteOrigin = `${req.protocol}://${req.get('host')}`; + const currentPath = req.originalUrl.split('?')[0] ?? req.path; + + return { + title: page.title, + shopName, + cartTotalQty, + feedback, + authorizedOrders, + shopNavActive, + shopFiatCurrency, + fiatPerXmr, + logoUrl, + faviconUrl, + simplexLink, + shippingNote, + themePreference, + pageMeta: this.buildPageMeta(siteOrigin, shopName, shopFiatCurrency, currentPath, page) + }; + } + + private buildAuthorizedOrderNavItems(req: Request, res: Response): AuthorizedOrderNavItem[] { + return this.orderAuthCookieService.getAuthorizedOrderIds(req, res).map(orderId => ({ + orderId, + label: `Order ${formatShortOrderId(orderId)}`, + href: `/shop/order/${orderId}`, + logoutHref: `/shop/order/${orderId}/logout` + })); + } + + private buildPageMeta( + siteOrigin: string, + shopName: string, + shopFiatCurrency: ShopFiatCurrency, + currentPath: string, + { title, metaDescription, ogType = 'website', ogImageUrl, product }: StorefrontPageMetaInput + ): StorefrontPageMeta { + return { + title, + documentTitle: `${title} - ${shopName}`, + metaDescription: metaDescription.replaceAll('{shopName}', shopName), + canonicalUrl: `${siteOrigin}${currentPath}`, + ogType, + ogImageUrl: toAbsoluteUrl(siteOrigin, ogImageUrl), + productJsonLd: product + ? this.buildProductJsonLd(siteOrigin, shopFiatCurrency, currentPath, title, product) + : null + }; + } + + private buildProductJsonLd( + siteOrigin: string, + shopFiatCurrency: ShopFiatCurrency, + path: string, + name: string, + { imageUrl, price, inStock }: StorefrontProductJsonLdInput + ): string { + const url = `${siteOrigin}${path}`; + const data: Record = { + '@context': 'https://schema.org', + '@type': 'Product', + name, + url, + offers: { + '@type': 'Offer', + price: String(price), + priceCurrency: shopFiatCurrency, + availability: inStock ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock', + url + } + }; + + const absoluteImageUrl = toAbsoluteUrl(siteOrigin, imageUrl); + + if (absoluteImageUrl) { + data.image = absoluteImageUrl; + } + + return JSON.stringify(data); + } +} diff --git a/backend/src/modules/storefrontCore/services/StorefrontSignedCookieService.ts b/backend/src/modules/storefrontCore/services/StorefrontSignedCookieService.ts new file mode 100644 index 0000000..9fa99cb --- /dev/null +++ b/backend/src/modules/storefrontCore/services/StorefrontSignedCookieService.ts @@ -0,0 +1,130 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { Request, Response } from 'express'; +import jwt from 'jsonwebtoken'; +import type { Config } from '../../../types/Config'; +import type { NodeEnv } from '../../../types/NodeEnv'; +import { shouldUseSecureCookie } from '../../../utils/shouldUseSecureCookie'; +import type { SignedCookieProfileKey } from '../types/SignedCookieProfileKey'; + +@Injectable() +export class StorefrontSignedCookieService { + constructor(private readonly configService: ConfigService) {} + + private readonly maxRawJwtCookieUtf8Bytes = 4096; + + private getCookieByName(req: Request, name: string): string | undefined { + const value: unknown = req.cookies?.[name]; + + if (value === undefined || value === '' || typeof value !== 'string') { + return undefined; + } + + return value; + } + + private getSignedCookieOptions( + req: Request, + maxAgeMs: number + ): { httpOnly: true; sameSite: 'lax'; secure: boolean; path: string; maxAge: number } { + const nodeEnv = this.configService.get('app.nodeEnv')!; + + return { + httpOnly: true, + sameSite: 'lax', + secure: shouldUseSecureCookie(nodeEnv, req), + path: '/', + maxAge: maxAgeMs + }; + } + + private clearSignedCookie(req: Request, res: Response, cookieName: string, maxAgeMs: number): void { + const { path, httpOnly, sameSite, secure } = this.getSignedCookieOptions(req, maxAgeMs); + + res.clearCookie(cookieName, { path, httpOnly, sameSite, secure }); + } + + clearSignedCookieByProfile(req: Request, res: Response, profileKey: SignedCookieProfileKey): void { + const config = this.configService.get('app.signedCookie') as Config['app']['signedCookie']; + + const { cookieName, expiresInMs } = config[profileKey]; + + this.clearSignedCookie(req, res, cookieName, expiresInMs); + } + + getSignedCookie

( + req: Request, + res: Response, + profileKey: SignedCookieProfileKey, + { consume = false }: { consume?: boolean } = {} + ): P | undefined { + const config = this.configService.get('app.signedCookie') as Config['app']['signedCookie']; + + const { cookieName, expiresInMs } = config[profileKey]; + + const raw = this.getCookieByName(req, cookieName); + + if (raw === undefined) { + return undefined; + } + + if (Buffer.byteLength(raw, 'utf8') > this.maxRawJwtCookieUtf8Bytes) { + this.clearSignedCookie(req, res, cookieName, expiresInMs); + + return undefined; + } + + try { + const decoded = jwt.verify(raw, config.jwtSecret) as P; + + if (consume) { + this.clearSignedCookie(req, res, cookieName, expiresInMs); + } + + return decoded; + } catch { + this.clearSignedCookie(req, res, cookieName, expiresInMs); + + return undefined; + } + } + + setSignedCookie( + req: Request, + res: Response, + profileKey: SignedCookieProfileKey, + payload: Record + ): void { + const config = this.configService.get('app.signedCookie') as Config['app']['signedCookie']; + + const { cookieName, expiresInMs } = config[profileKey]; + + const token = jwt.sign(payload, config.jwtSecret, { + expiresIn: Math.floor(expiresInMs / 1000) + }); + + if (Buffer.byteLength(token, 'utf8') > this.maxRawJwtCookieUtf8Bytes) { + throw new BadRequestException(this.getCookieTooLargeMessage(profileKey)); + } + + const cookieOptions = this.getSignedCookieOptions(req, expiresInMs); + + res.cookie(cookieName, token, cookieOptions); + } + + private getCookieTooLargeMessage(profileKey: SignedCookieProfileKey): string { + if (profileKey === 'cart') { + return 'Your cart is full. Remove some items and try again.'; + } + + if (profileKey === 'discount') { + return 'Too many discount codes. Remove one and try again.'; + } + + if (profileKey === 'orderAuth') { + return 'Too many saved orders in this browser. Open an order with its access token again.'; + } + + return 'Something went wrong. Please try again.'; + } +} diff --git a/backend/src/modules/storefrontCore/services/StorefrontThemeCookieService.spec.ts b/backend/src/modules/storefrontCore/services/StorefrontThemeCookieService.spec.ts new file mode 100644 index 0000000..c96f26d --- /dev/null +++ b/backend/src/modules/storefrontCore/services/StorefrontThemeCookieService.spec.ts @@ -0,0 +1,28 @@ +import type { StorefrontSignedCookieService } from './StorefrontSignedCookieService'; +import { StorefrontThemeCookieService } from './StorefrontThemeCookieService'; + +describe('StorefrontThemeCookieService', () => { + let signedCookies: jest.Mocked>; + let service: StorefrontThemeCookieService; + + beforeEach(() => { + signedCookies = { + getSignedCookie: jest.fn(), + setSignedCookie: jest.fn() + }; + + service = new StorefrontThemeCookieService(signedCookies as unknown as StorefrontSignedCookieService); + }); + + it('returns undefined for invalid theme values', () => { + signedCookies.getSignedCookie.mockReturnValue({ theme: 'system' }); + + expect(service.getTheme({} as never, {} as never)).toBeUndefined(); + }); + + it('returns a supported theme preference', () => { + signedCookies.getSignedCookie.mockReturnValue({ theme: 'dark' }); + + expect(service.getTheme({} as never, {} as never)).toBe('dark'); + }); +}); diff --git a/backend/src/modules/storefrontCore/services/StorefrontThemeCookieService.ts b/backend/src/modules/storefrontCore/services/StorefrontThemeCookieService.ts new file mode 100644 index 0000000..f2de8f6 --- /dev/null +++ b/backend/src/modules/storefrontCore/services/StorefrontThemeCookieService.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import type { StorefrontThemePreference } from '../types/StorefrontThemePreference'; +import { StorefrontSignedCookieService } from './StorefrontSignedCookieService'; + +@Injectable() +export class StorefrontThemeCookieService { + constructor(private readonly signedCookies: StorefrontSignedCookieService) {} + + getTheme(req: Request, res: Response): StorefrontThemePreference | undefined { + const payload = this.signedCookies.getSignedCookie<{ theme: string }>(req, res, 'theme'); + const theme = payload?.theme; + + if (theme === 'light' || theme === 'dark') { + return theme; + } + + return undefined; + } + + setTheme(req: Request, res: Response, theme: StorefrontThemePreference): void { + this.signedCookies.setSignedCookie(req, res, 'theme', { theme }); + } +} diff --git a/backend/src/modules/storefrontCore/types/AuthorizedOrderNavItem.ts b/backend/src/modules/storefrontCore/types/AuthorizedOrderNavItem.ts new file mode 100644 index 0000000..71f456f --- /dev/null +++ b/backend/src/modules/storefrontCore/types/AuthorizedOrderNavItem.ts @@ -0,0 +1,6 @@ +export type AuthorizedOrderNavItem = { + orderId: string; + label: string; + href: string; + logoutHref: string; +}; diff --git a/backend/src/modules/storefrontCore/types/CheckoutSessionCookiePayload.ts b/backend/src/modules/storefrontCore/types/CheckoutSessionCookiePayload.ts new file mode 100644 index 0000000..b2426f2 --- /dev/null +++ b/backend/src/modules/storefrontCore/types/CheckoutSessionCookiePayload.ts @@ -0,0 +1,3 @@ +export type CheckoutSessionCookiePayload = { + sessionId: string; +}; diff --git a/backend/src/modules/storefrontCore/types/HandlebarsBlockOptions.ts b/backend/src/modules/storefrontCore/types/HandlebarsBlockOptions.ts new file mode 100644 index 0000000..fdadddc --- /dev/null +++ b/backend/src/modules/storefrontCore/types/HandlebarsBlockOptions.ts @@ -0,0 +1,4 @@ +export type HandlebarsBlockOptions = { + fn: (context: unknown) => string; + inverse: (context: unknown) => string; +}; diff --git a/backend/src/modules/storefrontCore/types/OrderAuthCookiePayload.ts b/backend/src/modules/storefrontCore/types/OrderAuthCookiePayload.ts new file mode 100644 index 0000000..2e0a978 --- /dev/null +++ b/backend/src/modules/storefrontCore/types/OrderAuthCookiePayload.ts @@ -0,0 +1,3 @@ +export type OrderAuthCookiePayload = { + orderIds: string[]; +}; diff --git a/backend/src/modules/storefrontCore/types/ShopNavActive.ts b/backend/src/modules/storefrontCore/types/ShopNavActive.ts new file mode 100644 index 0000000..ea0196a --- /dev/null +++ b/backend/src/modules/storefrontCore/types/ShopNavActive.ts @@ -0,0 +1,6 @@ +import type { ShopNavKey } from './ShopNavKey'; + +export type ShopNavActive = { + activeShopNav: ShopNavKey | null; + activeOrderId: string | null; +}; diff --git a/backend/src/modules/storefrontCore/types/ShopNavKey.ts b/backend/src/modules/storefrontCore/types/ShopNavKey.ts new file mode 100644 index 0000000..8a86d42 --- /dev/null +++ b/backend/src/modules/storefrontCore/types/ShopNavKey.ts @@ -0,0 +1 @@ +export type ShopNavKey = 'shop' | 'cart' | 'check-order'; diff --git a/backend/src/modules/storefrontCore/types/ShopRenderLocals.ts b/backend/src/modules/storefrontCore/types/ShopRenderLocals.ts new file mode 100644 index 0000000..ab1770b --- /dev/null +++ b/backend/src/modules/storefrontCore/types/ShopRenderLocals.ts @@ -0,0 +1,23 @@ +import type { ShopFiatCurrency } from '../../../types/ShopFiatCurrency'; +import type { AuthorizedOrderNavItem } from './AuthorizedOrderNavItem'; +import type { ShopNavActive } from './ShopNavActive'; +import type { StorefrontFeedback } from './StorefrontFeedback'; +import type { StorefrontPageMeta } from './StorefrontPageMeta'; +import type { StorefrontThemePreference } from './StorefrontThemePreference'; + +export type ShopRenderLocals = { + title: string; + shopName: string; + cartTotalQty: number; + feedback: StorefrontFeedback | undefined; + authorizedOrders: AuthorizedOrderNavItem[]; + shopNavActive: ShopNavActive; + shopFiatCurrency: ShopFiatCurrency; + fiatPerXmr: number | null; + logoUrl: string | null; + faviconUrl: string | null; + simplexLink: string | null; + shippingNote: string | null; + themePreference: StorefrontThemePreference | undefined; + pageMeta: StorefrontPageMeta; +}; diff --git a/backend/src/modules/storefrontCore/types/SignedCookieProfileKey.ts b/backend/src/modules/storefrontCore/types/SignedCookieProfileKey.ts new file mode 100644 index 0000000..a4edc93 --- /dev/null +++ b/backend/src/modules/storefrontCore/types/SignedCookieProfileKey.ts @@ -0,0 +1,9 @@ +export type SignedCookieProfileKey = + | 'cart' + | 'captcha' + | 'discount' + | 'error' + | 'feedback' + | 'checkoutSession' + | 'orderAuth' + | 'theme'; diff --git a/backend/src/modules/storefrontCore/types/StorefrontCaptchaCookiePayload.ts b/backend/src/modules/storefrontCore/types/StorefrontCaptchaCookiePayload.ts new file mode 100644 index 0000000..6928f37 --- /dev/null +++ b/backend/src/modules/storefrontCore/types/StorefrontCaptchaCookiePayload.ts @@ -0,0 +1,3 @@ +export type StorefrontCaptchaCookiePayload = { + answer: string; +}; diff --git a/backend/src/modules/storefrontCore/types/StorefrontDiscountView.ts b/backend/src/modules/storefrontCore/types/StorefrontDiscountView.ts new file mode 100644 index 0000000..8308ce0 --- /dev/null +++ b/backend/src/modules/storefrontCore/types/StorefrontDiscountView.ts @@ -0,0 +1,4 @@ +export type StorefrontDiscountView = { + code: string; + amountFiat: number; +}; diff --git a/backend/src/modules/storefrontCore/types/StorefrontErrorPayload.ts b/backend/src/modules/storefrontCore/types/StorefrontErrorPayload.ts new file mode 100644 index 0000000..ccdc9c0 --- /dev/null +++ b/backend/src/modules/storefrontCore/types/StorefrontErrorPayload.ts @@ -0,0 +1,4 @@ +export type StorefrontErrorPayload = { + statusCode: number; + message: string; +}; diff --git a/backend/src/modules/storefrontCore/types/StorefrontFeedback.ts b/backend/src/modules/storefrontCore/types/StorefrontFeedback.ts new file mode 100644 index 0000000..43f95a0 --- /dev/null +++ b/backend/src/modules/storefrontCore/types/StorefrontFeedback.ts @@ -0,0 +1,4 @@ +export type StorefrontFeedback = { + type: 'success' | 'error'; + text: string; +}; diff --git a/backend/src/modules/storefrontCore/types/StorefrontInvoicePaymentView.ts b/backend/src/modules/storefrontCore/types/StorefrontInvoicePaymentView.ts new file mode 100644 index 0000000..4c8ef95 --- /dev/null +++ b/backend/src/modules/storefrontCore/types/StorefrontInvoicePaymentView.ts @@ -0,0 +1,8 @@ +import type { InvoicePaymentConfirmationVariant } from '../../../utils/invoice/types/InvoicePaymentConfirmationVariant'; + +export type StorefrontInvoicePaymentView = { + txHash: string; + amountCrypto: string; + confirmationStatus: string; + confirmationStatusVariant: InvoicePaymentConfirmationVariant; +}; diff --git a/backend/src/modules/storefrontCore/types/StorefrontInvoiceView.ts b/backend/src/modules/storefrontCore/types/StorefrontInvoiceView.ts new file mode 100644 index 0000000..69e6b68 --- /dev/null +++ b/backend/src/modules/storefrontCore/types/StorefrontInvoiceView.ts @@ -0,0 +1,29 @@ +import type { StorefrontInvoicePaymentView } from './StorefrontInvoicePaymentView'; +import type { InvoiceStatusLabel } from '../../../utils/invoice/types/InvoiceStatusLabel'; +import type { InvoiceStatusVariant } from '../../../utils/invoice/types/InvoiceStatusVariant'; + +export type StorefrontInvoiceView = { + cryptoCurrency: string; + expectedTotalCrypto: string; + receivedTotalCrypto: string | null; + paymentAddress: string; + qrCodeUrl: string | null; + instructionPrefix: string | null; + instructionAmountCrypto: string | null; + instructionSuffix: string | null; + expiresInDuration: string | null; + payments: StorefrontInvoicePaymentView[]; + isPaidSufficient: boolean; + showStatusMessage: boolean; + statusMessage: InvoiceStatusLabel | null; + statusVariant: InvoiceStatusVariant | null; + showProminentAmount: boolean; + showExpectedTotal: boolean; + showReceivedTotal: boolean; + showInstruction: boolean; + showExpiry: boolean; + showQr: boolean; + showAddress: boolean; + showPayments: boolean; + showRefresh: boolean; +}; diff --git a/backend/src/modules/storefrontCore/types/StorefrontPageMeta.ts b/backend/src/modules/storefrontCore/types/StorefrontPageMeta.ts new file mode 100644 index 0000000..b59d292 --- /dev/null +++ b/backend/src/modules/storefrontCore/types/StorefrontPageMeta.ts @@ -0,0 +1,9 @@ +export type StorefrontPageMeta = { + title: string; + documentTitle: string; + metaDescription: string; + canonicalUrl: string; + ogType: string; + ogImageUrl: string | null; + productJsonLd: string | null; +}; diff --git a/backend/src/modules/storefrontCore/types/StorefrontPageMetaInput.ts b/backend/src/modules/storefrontCore/types/StorefrontPageMetaInput.ts new file mode 100644 index 0000000..d0d846a --- /dev/null +++ b/backend/src/modules/storefrontCore/types/StorefrontPageMetaInput.ts @@ -0,0 +1,9 @@ +import type { StorefrontProductJsonLdInput } from './StorefrontProductJsonLdInput'; + +export type StorefrontPageMetaInput = { + title: string; + metaDescription: string; + ogType?: string; + ogImageUrl?: string | null; + product?: StorefrontProductJsonLdInput; +}; diff --git a/backend/src/modules/storefrontCore/types/StorefrontProductJsonLdInput.ts b/backend/src/modules/storefrontCore/types/StorefrontProductJsonLdInput.ts new file mode 100644 index 0000000..84a8cd7 --- /dev/null +++ b/backend/src/modules/storefrontCore/types/StorefrontProductJsonLdInput.ts @@ -0,0 +1,5 @@ +export type StorefrontProductJsonLdInput = { + imageUrl: string | null; + price: number; + inStock: boolean; +}; diff --git a/backend/src/modules/storefrontCore/types/StorefrontThemePreference.ts b/backend/src/modules/storefrontCore/types/StorefrontThemePreference.ts new file mode 100644 index 0000000..06b14c2 --- /dev/null +++ b/backend/src/modules/storefrontCore/types/StorefrontThemePreference.ts @@ -0,0 +1 @@ +export type StorefrontThemePreference = 'light' | 'dark'; diff --git a/backend/src/modules/storefrontCore/types/cart/CookieCart.ts b/backend/src/modules/storefrontCore/types/cart/CookieCart.ts new file mode 100644 index 0000000..cba1368 --- /dev/null +++ b/backend/src/modules/storefrontCore/types/cart/CookieCart.ts @@ -0,0 +1,3 @@ +import type { CookieCartLine } from './CookieCartLine'; + +export type CookieCart = CookieCartLine[]; diff --git a/backend/src/modules/storefrontCore/types/cart/CookieCartLine.ts b/backend/src/modules/storefrontCore/types/cart/CookieCartLine.ts new file mode 100644 index 0000000..00e731f --- /dev/null +++ b/backend/src/modules/storefrontCore/types/cart/CookieCartLine.ts @@ -0,0 +1,4 @@ +export interface CookieCartLine { + variantId: string; + qty: number; +} diff --git a/backend/src/modules/storefrontCore/utils/getHttpExceptionUserMessage.ts b/backend/src/modules/storefrontCore/utils/getHttpExceptionUserMessage.ts new file mode 100644 index 0000000..0641e57 --- /dev/null +++ b/backend/src/modules/storefrontCore/utils/getHttpExceptionUserMessage.ts @@ -0,0 +1,31 @@ +import type { HttpException } from '@nestjs/common'; + +export const getHttpExceptionUserMessage = (exception: HttpException, fallback: string): string => { + const response = exception.getResponse(); + + if (typeof response === 'string') { + return response; + } + + if (typeof response === 'object' && response !== null && 'message' in response) { + const { message } = response; + + if (typeof message === 'string') { + return message; + } + + if (Array.isArray(message)) { + const texts = message.filter((item): item is string => typeof item === 'string'); + + if (texts.length === 1) { + return texts[0]; + } + + if (texts.length > 1) { + return texts.join(', '); + } + } + } + + return fallback; +}; diff --git a/backend/src/modules/storefrontCore/utils/registerStorefrontHelpers.ts b/backend/src/modules/storefrontCore/utils/registerStorefrontHelpers.ts new file mode 100644 index 0000000..5d0277b --- /dev/null +++ b/backend/src/modules/storefrontCore/utils/registerStorefrontHelpers.ts @@ -0,0 +1,14 @@ +import type hbs from 'hbs'; +import type { HandlebarsBlockOptions } from '../types/HandlebarsBlockOptions'; + +export const registerStorefrontHelpers = (engine: typeof hbs): void => { + engine.registerHelper('ifeq', function (this: unknown, a: unknown, b: unknown, options: HandlebarsBlockOptions) { + return a === b ? options.fn(this) : options.inverse(this); + }); + + engine.registerHelper('includes', (items: unknown, value: unknown) => { + return Array.isArray(items) && items.includes(value); + }); + + engine.registerHelper('gt', (a: unknown, b: unknown) => Number(a) > Number(b)); +}; diff --git a/backend/src/modules/storefrontCore/utils/registerStorefrontPartials.ts b/backend/src/modules/storefrontCore/utils/registerStorefrontPartials.ts new file mode 100644 index 0000000..a2d009d --- /dev/null +++ b/backend/src/modules/storefrontCore/utils/registerStorefrontPartials.ts @@ -0,0 +1,46 @@ +import type hbs from 'hbs'; +import fs from 'node:fs'; +import path from 'node:path'; + +const STOREFRONT_PARTIALS = [ + { name: 'feedback', file: 'feedback.hbs' }, + { name: 'nav-link', file: 'nav-link.hbs' }, + { name: 'order-chat', file: 'order-chat.hbs' }, + { name: 'qty-input', file: 'qty-input.hbs' }, + { name: 'refresh-link', file: 'refresh-link.hbs' }, + { name: 'cart-totals-panel', file: 'cart-totals-panel.hbs' }, + { name: 'cart-line-item', file: 'cart-line-item.hbs' }, + { name: 'checkout-line-item', file: 'checkout-line-item.hbs' }, + { name: 'confirm-action-details', file: 'confirm-action-details.hbs' }, + { name: 'dismiss-button', file: 'dismiss-button.hbs' }, + { name: 'order-line-item', file: 'order-line-item.hbs' }, + { name: 'order-digital-delivery', file: 'order-digital-delivery.hbs' }, + { name: 'order-manual-fulfillment', file: 'order-manual-fulfillment.hbs' }, + { name: 'invoice-payment', file: 'invoice-payment.hbs' }, + { name: 'order-shipping-payment', file: 'order-shipping-payment.hbs' }, + { name: 'summary-totals-lines', file: 'summary-totals-lines.hbs' }, + { name: 'summary-totals', file: 'summary-totals.hbs' }, + { name: 'summary-grand-totals', file: 'summary-grand-totals.hbs' }, + { name: 'manual-shipping-info', file: 'manual-shipping-info.hbs' }, + { name: 'auto-delivery-info', file: 'auto-delivery-info.hbs' }, + { name: 'shop-nav', file: 'shop-nav.hbs' }, + { name: 'shop-head', file: 'shop-head.hbs' }, + { name: 'shop-favicon', file: 'shop-favicon.hbs' }, + { name: 'shop-footer', file: 'shop-footer.hbs' }, + { name: 'theme-switcher', file: 'theme-switcher.hbs' }, + { name: 'page-back-link', file: 'page-back-link.hbs' }, + { name: 'product-card', file: 'product-card.hbs' }, + { name: 'order-data-retention-notice', file: 'order-data-retention-notice.hbs' }, + { name: 'category-nav', file: 'category-nav.hbs' }, + { name: 'product-delivery-note', file: 'product-delivery-note.hbs' }, + { name: 'variant-picker-link', file: 'variant-picker-link.hbs' }, + { name: 'variant-image-gallery', file: 'variant-image-gallery.hbs' } +] as const; + +export const registerStorefrontPartials = (engine: typeof hbs, partialsDir: string): void => { + for (const { name, file } of STOREFRONT_PARTIALS) { + const template = fs.readFileSync(path.join(partialsDir, file), 'utf8'); + + engine.registerPartial(name, template); + } +}; diff --git a/backend/src/modules/storefrontCore/utils/resolveShopNavActive.spec.ts b/backend/src/modules/storefrontCore/utils/resolveShopNavActive.spec.ts new file mode 100644 index 0000000..f496403 --- /dev/null +++ b/backend/src/modules/storefrontCore/utils/resolveShopNavActive.spec.ts @@ -0,0 +1,34 @@ +import { resolveShopNavActive } from './resolveShopNavActive'; + +describe('resolveShopNavActive', () => { + it('highlights shop on the home and catalog pages', () => { + expect(resolveShopNavActive('/')).toEqual({ activeShopNav: 'shop', activeOrderId: null }); + expect(resolveShopNavActive('/shop/categories/cat-1')).toEqual({ + activeShopNav: 'shop', + activeOrderId: null + }); + expect(resolveShopNavActive('/shop/products/prod-1/variants/var-1')).toEqual({ + activeShopNav: 'shop', + activeOrderId: null + }); + }); + + it('highlights cart and check-order pages', () => { + expect(resolveShopNavActive('/shop/cart')).toEqual({ activeShopNav: 'cart', activeOrderId: null }); + expect(resolveShopNavActive('/shop/check-order')).toEqual({ + activeShopNav: 'check-order', + activeOrderId: null + }); + }); + + it('highlights the current order page only', () => { + expect(resolveShopNavActive('/shop/order/order-uuid')).toEqual({ + activeShopNav: null, + activeOrderId: 'order-uuid' + }); + }); + + it('does not highlight nav items on unrelated pages', () => { + expect(resolveShopNavActive('/shop/checkout')).toEqual({ activeShopNav: null, activeOrderId: null }); + }); +}); diff --git a/backend/src/modules/storefrontCore/utils/resolveShopNavActive.ts b/backend/src/modules/storefrontCore/utils/resolveShopNavActive.ts new file mode 100644 index 0000000..bf6bd69 --- /dev/null +++ b/backend/src/modules/storefrontCore/utils/resolveShopNavActive.ts @@ -0,0 +1,27 @@ +import type { ShopNavActive } from '../types/ShopNavActive'; + +export const resolveShopNavActive = (path: string): ShopNavActive => { + const [first, second, third] = path.split('/').filter(Boolean); + + if (!first) { + return { activeShopNav: 'shop', activeOrderId: null }; + } + + if (first === 'shop' && second === 'cart') { + return { activeShopNav: 'cart', activeOrderId: null }; + } + + if (first === 'shop' && second === 'check-order') { + return { activeShopNav: 'check-order', activeOrderId: null }; + } + + if (first === 'shop' && second === 'order' && third) { + return { activeShopNav: null, activeOrderId: third }; + } + + if (first === 'shop' && (second === 'categories' || second === 'products')) { + return { activeShopNav: 'shop', activeOrderId: null }; + } + + return { activeShopNav: null, activeOrderId: null }; +}; diff --git a/backend/src/modules/storefrontCore/views/cart-summary.hbs b/backend/src/modules/storefrontCore/views/cart-summary.hbs new file mode 100644 index 0000000..b82171f --- /dev/null +++ b/backend/src/modules/storefrontCore/views/cart-summary.hbs @@ -0,0 +1,18 @@ +{{> page-back-link href='/' label='Shop'}} + +

{{title}}

+ +{{#if cartExtended}} +
+
+
    + {{#each cartExtended}} + {{> cart-line-item}} + {{/each}} +
+
+ {{> cart-totals-panel}} +
+{{else}} +

Your cart is empty.

+{{/if}} diff --git a/backend/src/modules/storefrontCore/views/checkout.hbs b/backend/src/modules/storefrontCore/views/checkout.hbs new file mode 100644 index 0000000..910fddc --- /dev/null +++ b/backend/src/modules/storefrontCore/views/checkout.hbs @@ -0,0 +1,47 @@ +

{{title}}

+ +
+
+

Order summary

+
    + {{#each checkout.lines}} + {{> checkout-line-item}} + {{/each}} +
+
+ + +
diff --git a/backend/src/modules/storefrontCore/views/layouts/shop-minimal.hbs b/backend/src/modules/storefrontCore/views/layouts/shop-minimal.hbs new file mode 100644 index 0000000..5bd9662 --- /dev/null +++ b/backend/src/modules/storefrontCore/views/layouts/shop-minimal.hbs @@ -0,0 +1,17 @@ + + + + {{> shop-head}} + + +
+
+ {{{body}}} +
+
+ + diff --git a/backend/src/modules/storefrontCore/views/layouts/shop.hbs b/backend/src/modules/storefrontCore/views/layouts/shop.hbs new file mode 100644 index 0000000..f49018b --- /dev/null +++ b/backend/src/modules/storefrontCore/views/layouts/shop.hbs @@ -0,0 +1,21 @@ + + + + {{> shop-head}} + + +
+ {{> shop-nav}} + {{> category-nav}} +
+ {{> feedback}} + {{{body}}} +
+ {{> shop-footer}} +
+ + diff --git a/backend/src/modules/storefrontCore/views/order-check.hbs b/backend/src/modules/storefrontCore/views/order-check.hbs new file mode 100644 index 0000000..1537982 --- /dev/null +++ b/backend/src/modules/storefrontCore/views/order-check.hbs @@ -0,0 +1,15 @@ +

{{title}}

+ +
+

+ Enter the order access token you received after payment to view your order status. +

+ +
+ + +
+
diff --git a/backend/src/modules/storefrontCore/views/order.hbs b/backend/src/modules/storefrontCore/views/order.hbs new file mode 100644 index 0000000..e26b290 --- /dev/null +++ b/backend/src/modules/storefrontCore/views/order.hbs @@ -0,0 +1,71 @@ +

{{title}}

+ +{{#if order.showAccessTokenBanner}} +
+

Save your order access token

+

+ This token is like a password to your order. Store it in a safe place and do not share it with anyone. +

+

{{order.accessToken}}

+ + {{> confirm-action-details + summary='Confirm and hide' + body='Only continue if you have stored your order access token somewhere safe. If you lose it, only support can help you open this order again.' + confirmAction=order.confirmAccessTokenSavedAction + confirmButtonLabel='Confirm and hide' + tone='warning' + }} +
+{{/if}} + +

Status: {{order.status}}

+ +{{#ifeq order.status 'unfulfillable'}} +

+ We received your payment but could not fulfill this order automatically. Use order chat below if you need help. +

+{{/ifeq}} + +
+
+
+

Order summary

+
    + {{#each order.lines}} + {{> order-line-item}} + {{/each}} +
+
+ + {{> order-chat}} + + {{> order-data-retention-notice}} +
+ + +
diff --git a/backend/src/modules/storefrontCore/views/partials/auto-delivery-info.hbs b/backend/src/modules/storefrontCore/views/partials/auto-delivery-info.hbs new file mode 100644 index 0000000..798f650 --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/auto-delivery-info.hbs @@ -0,0 +1,9 @@ +{{#if hasAutoLines}} +
+

This cart contains automatically delivered products.

+

How delivery works

+

+ These products are delivered on your order page automatically after payment is confirmed. +

+
+{{/if}} diff --git a/backend/src/modules/storefrontCore/views/partials/cart-line-item.hbs b/backend/src/modules/storefrontCore/views/partials/cart-line-item.hbs new file mode 100644 index 0000000..028955e --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/cart-line-item.hbs @@ -0,0 +1,47 @@ +
  • +
    + +
    + +
    {{price}} + {{@root.shopFiatCurrency}} + × + {{qty}} + = + {{lineSubtotal}} + {{@root.shopFiatCurrency}}
    +
    {{> product-delivery-note deliveryMode=deliveryMode}}
    + {{#if stockIssueMessage}} +

    {{stockIssueMessage}}

    + {{else}} + {{#unless stockForSession}} +

    No more in stock beside your order

    + {{/unless}} + {{/if}} + {{#each @root.discounts}} + {{#if (includes ineligibleVariantIds ../id)}} +

    Not eligible for code {{code}}

    + {{/if}} + {{/each}} +
    + + {{> qty-input qty=qty min=1 max=stockAvailable}} + +
    +
    +
    +
    + + +
    +
  • diff --git a/backend/src/modules/storefrontCore/views/partials/cart-totals-panel.hbs b/backend/src/modules/storefrontCore/views/partials/cart-totals-panel.hbs new file mode 100644 index 0000000..05235a8 --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/cart-totals-panel.hbs @@ -0,0 +1,96 @@ + diff --git a/backend/src/modules/storefrontCore/views/partials/category-nav.hbs b/backend/src/modules/storefrontCore/views/partials/category-nav.hbs new file mode 100644 index 0000000..36413d8 --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/category-nav.hbs @@ -0,0 +1,8 @@ +{{#if categories.length}} + +{{/if}} diff --git a/backend/src/modules/storefrontCore/views/partials/checkout-line-item.hbs b/backend/src/modules/storefrontCore/views/partials/checkout-line-item.hbs new file mode 100644 index 0000000..a8b3673 --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/checkout-line-item.hbs @@ -0,0 +1,40 @@ +
  • +
    +
    + {{#if linkHref}} + + {{#if thumbnailUrl}} + {{productTitle}} — {{variantTitle}} + {{else}} + no image + {{/if}} + + {{else}} + {{#if thumbnailUrl}} + {{productTitle}} — {{variantTitle}} + {{else}} + no image + {{/if}} + {{/if}} +
    +
    +
    + + {{#if linkHref}} + {{productTitle}} — {{variantTitle}} + {{else}} + {{productTitle}} — {{variantTitle}} + {{/if}} + +
    +
    {{unitPriceFiat}} + {{@root.shopFiatCurrency}} + × + {{qty}} + = + {{lineSubtotalFiat}} + {{@root.shopFiatCurrency}}
    +
    {{> product-delivery-note deliveryMode=deliveryMode}}
    +
    +
    +
  • diff --git a/backend/src/modules/storefrontCore/views/partials/confirm-action-details.hbs b/backend/src/modules/storefrontCore/views/partials/confirm-action-details.hbs new file mode 100644 index 0000000..269e00a --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/confirm-action-details.hbs @@ -0,0 +1,9 @@ +
    + {{summary}} +
    +

    {{body}}

    +
    + +
    +
    +
    diff --git a/backend/src/modules/storefrontCore/views/partials/dismiss-button.hbs b/backend/src/modules/storefrontCore/views/partials/dismiss-button.hbs new file mode 100644 index 0000000..7f3deed --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/dismiss-button.hbs @@ -0,0 +1,6 @@ +
    + {{#if hiddenName}} + + {{/if}} + +
    diff --git a/backend/src/modules/storefrontCore/views/partials/feedback.hbs b/backend/src/modules/storefrontCore/views/partials/feedback.hbs new file mode 100644 index 0000000..a389dee --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/feedback.hbs @@ -0,0 +1,5 @@ +{{#if feedback}} +

    + {{feedback.text}} +

    +{{/if}} diff --git a/backend/src/modules/storefrontCore/views/partials/invoice-payment.hbs b/backend/src/modules/storefrontCore/views/partials/invoice-payment.hbs new file mode 100644 index 0000000..d55537a --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/invoice-payment.hbs @@ -0,0 +1,63 @@ +{{#if showStatusMessage}} +

    {{statusMessage}}

    +{{/if}} + +{{#if showProminentAmount}} +

    {{expectedTotalCrypto}} {{cryptoCurrency}}

    +{{/if}} + +{{#if showReceivedTotal}} +

    + Received: + {{receivedTotalCrypto}} {{cryptoCurrency}} +

    +{{/if}} + +{{#if showExpectedTotal}} +

    + Expected total: + {{expectedTotalCrypto}} {{cryptoCurrency}} +

    +{{/if}} + +{{#if showInstruction}} +

    + {{instructionPrefix}} + {{instructionAmountCrypto}} {{cryptoCurrency}} + {{instructionSuffix}} + {{#if showExpiry}} + Payment expires in + {{expiresInDuration}}. + {{/if}} +

    +{{/if}} + +{{#if showQr}} +
    + {{cryptoCurrency}} payment QR code +
    +{{/if}} + +{{#if showAddress}} +

    {{#if showQr}}Address{{else}}Payment address{{/if}}

    +

    {{paymentAddress}}

    +{{/if}} + +{{#if showPayments}} +

    Transactions

    +
      + {{#each payments}} +
    • +
      {{amountCrypto}} {{../cryptoCurrency}}
      +
      {{txHash}}
      +
      {{confirmationStatus}}
      +
    • + {{/each}} +
    +{{/if}} + +{{#if showRefresh}} + {{#if refreshHref}} + {{> refresh-link href=refreshHref showAutoRefreshNote=showAutoRefreshNote}} + {{/if}} +{{/if}} diff --git a/backend/src/modules/storefrontCore/views/partials/manual-shipping-info.hbs b/backend/src/modules/storefrontCore/views/partials/manual-shipping-info.hbs new file mode 100644 index 0000000..724bc63 --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/manual-shipping-info.hbs @@ -0,0 +1,15 @@ +{{#if hasManualLines}} +
    +

    This cart contains manually delivered products.

    +

    How shipping works

    +

    + You pay for products first. We confirm exact shipping afterward on your order page. Use order chat to share + your delivery address and any shipping preferences. Once the shipping quote is published, pay shipping + separately. +

    + {{#if shippingNote}} +

    Typical shipping costs

    +

    {{shippingNote}}

    + {{/if}} +
    +{{/if}} diff --git a/backend/src/modules/storefrontCore/views/partials/nav-link.hbs b/backend/src/modules/storefrontCore/views/partials/nav-link.hbs new file mode 100644 index 0000000..b99fd7d --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/nav-link.hbs @@ -0,0 +1,5 @@ +{{#ifeq key active}} + {{label}}{{#if count}} ({{count}}){{/if}} +{{else}} + {{label}}{{#if count}} ({{count}}){{/if}} +{{/ifeq}} diff --git a/backend/src/modules/storefrontCore/views/partials/order-chat.hbs b/backend/src/modules/storefrontCore/views/partials/order-chat.hbs new file mode 100644 index 0000000..43af7bc --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/order-chat.hbs @@ -0,0 +1,50 @@ +
    +
    +

    Order chat (encrypted)

    + {{> refresh-link href=order.chatRefreshHref}} +
    + +

    + Use this chat for payment, shipping, or delivery questions about this order. +

    + +
    + {{#if order.chat.messages.length}} + {{#each order.chat.messages}} +
    +
    + {{#if isBuyer}} +
    + +
    + {{/if}} +

    {{body}}

    +
    + {{#if isBuyer}}You{{else}}Shop{{/if}} + · + {{sentAtLabel}} +
    +
    +
    + {{/each}} + {{else}} +

    No messages yet. Send the first one below.

    + {{/if}} +
    + +
    + + +
    +
    diff --git a/backend/src/modules/storefrontCore/views/partials/order-data-retention-notice.hbs b/backend/src/modules/storefrontCore/views/partials/order-data-retention-notice.hbs new file mode 100644 index 0000000..b23865b --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/order-data-retention-notice.hbs @@ -0,0 +1,6 @@ +

    + All your order data — including order details, payment records, chat messages, and delivery content — is + automatically deleted after + {{order.dataRetentionDays}} + days. +

    diff --git a/backend/src/modules/storefrontCore/views/partials/order-digital-delivery.hbs b/backend/src/modules/storefrontCore/views/partials/order-digital-delivery.hbs new file mode 100644 index 0000000..417650c --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/order-digital-delivery.hbs @@ -0,0 +1,19 @@ +
    +

    Your delivery

    +
      + {{#each deliveries}} +
    • +
      {{content}}
      + {{#if attachments.length}} + + {{/if}} +
    • + {{/each}} +
    +
    diff --git a/backend/src/modules/storefrontCore/views/partials/order-line-item.hbs b/backend/src/modules/storefrontCore/views/partials/order-line-item.hbs new file mode 100644 index 0000000..b2adab5 --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/order-line-item.hbs @@ -0,0 +1,54 @@ +
  • +
    +
    + {{#if linkHref}} + + {{#if thumbnailUrl}} + {{productTitle}} — {{variantTitle}} + {{else}} + no image + {{/if}} + + {{else}} + {{#if thumbnailUrl}} + {{productTitle}} — {{variantTitle}} + {{else}} + no image + {{/if}} + {{/if}} +
    +
    +
    + + {{#if linkHref}} + {{productTitle}} — {{variantTitle}} + {{else}} + {{productTitle}} — {{variantTitle}} + {{/if}} + +
    +
    {{unitPriceFiat}} + {{@root.shopFiatCurrency}} + × + {{qty}} + = + {{lineSubtotalFiat}} + {{@root.shopFiatCurrency}}
    +
    + {{#ifeq deliveryMode 'auto'}} + {{#if canShowAutoDelivery}} + {{> order-digital-delivery deliveries=digitalDeliveries}} + {{else}} +

    Delivery will appear here after payment is confirmed.

    + {{/if}} + {{else}} + {{#if manualFulfillment}} + {{> order-manual-fulfillment fulfillment=manualFulfillment}} + {{else}} + {{> product-delivery-note deliveryMode=deliveryMode}} + {{/if}} + {{/ifeq}} +
    +
    +
    +
  • diff --git a/backend/src/modules/storefrontCore/views/partials/order-manual-fulfillment.hbs b/backend/src/modules/storefrontCore/views/partials/order-manual-fulfillment.hbs new file mode 100644 index 0000000..bad665c --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/order-manual-fulfillment.hbs @@ -0,0 +1,5 @@ +{{#ifeq fulfillment.status 'fulfilled'}} +

    Fulfilled

    +{{else}} +

    Awaiting fulfillment after payment is confirmed.

    +{{/ifeq}} diff --git a/backend/src/modules/storefrontCore/views/partials/order-shipping-payment.hbs b/backend/src/modules/storefrontCore/views/partials/order-shipping-payment.hbs new file mode 100644 index 0000000..9ba695d --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/order-shipping-payment.hbs @@ -0,0 +1,30 @@ +
    +

    Shipping payment

    + + {{#unless @root.order.isShippingQuoted}} +

    + Please wait for the shipping quote. We will prepare and publish your shipping cost here manually. +

    +

    + {{> refresh-link href=@root.order.shippingPaymentRefreshHref}} +

    + {{else}} +

    + Shipping cost: + {{@root.order.totals.shippingCostFiat}} + {{shopFiatCurrency}} +

    + + {{#ifeq @root.order.totals.shippingCostFiat 0}} +

    + No shipping payment is required for this order. +

    + {{/ifeq}} + + {{#if @root.order.shippingInvoice}} + {{#with @root.order.shippingInvoice}} + {{> invoice-payment refreshHref=@root.order.shippingPaymentRefreshHref}} + {{/with}} + {{/if}} + {{/unless}} +
    diff --git a/backend/src/modules/storefrontCore/views/partials/page-back-link.hbs b/backend/src/modules/storefrontCore/views/partials/page-back-link.hbs new file mode 100644 index 0000000..e79e0fd --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/page-back-link.hbs @@ -0,0 +1 @@ + diff --git a/backend/src/modules/storefrontCore/views/partials/product-card.hbs b/backend/src/modules/storefrontCore/views/partials/product-card.hbs new file mode 100644 index 0000000..4e30cbf --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/product-card.hbs @@ -0,0 +1,54 @@ +
  • + {{#with selectedVariant}} + {{#if thumbnailUrl}} + + {{productTitle}} - {{title}} + + {{/if}} + {{/with}} + + {{#if cardVariants.length}} +
    +
    + {{#each cardVariants}} + {{> variant-picker-link}} + {{/each}} +
    + {{#if hiddenVariantCount}} + +{{hiddenVariantCount}} + {{/if}} +
    + {{else}} +

    Out of stock

    + {{/if}} + + {{#with selectedVariant}} + +
    {{price}} {{@root.shopFiatCurrency}}
    + {{> product-delivery-note deliveryMode=../deliveryMode}} + {{#if stockForSession}} +

    In stock: {{stockForSession}}

    + {{else}} +

    Out of stock

    + {{/if}} +
    + + {{#if stockForSession}} + {{> qty-input qty=1 min=1 max=stockForSession}} + + {{else}} + + {{/if}} +
    + {{/with}} +
  • diff --git a/backend/src/modules/storefrontCore/views/partials/product-delivery-note.hbs b/backend/src/modules/storefrontCore/views/partials/product-delivery-note.hbs new file mode 100644 index 0000000..d68cf6d --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/product-delivery-note.hbs @@ -0,0 +1,6 @@ +{{#ifeq deliveryMode 'manual'}} +

    Manual delivery

    +{{/ifeq}} +{{#ifeq deliveryMode 'auto'}} +

    Auto delivery

    +{{/ifeq}} diff --git a/backend/src/modules/storefrontCore/views/partials/qty-input.hbs b/backend/src/modules/storefrontCore/views/partials/qty-input.hbs new file mode 100644 index 0000000..ecf303b --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/qty-input.hbs @@ -0,0 +1,13 @@ + diff --git a/backend/src/modules/storefrontCore/views/partials/refresh-link.hbs b/backend/src/modules/storefrontCore/views/partials/refresh-link.hbs new file mode 100644 index 0000000..d56538f --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/refresh-link.hbs @@ -0,0 +1,10 @@ +{{#if showAutoRefreshNote}} +

    + Automatically refreshes every {{@root.refreshSec}} seconds. +

    + +{{else}} + Refresh +{{/if}} diff --git a/backend/src/modules/storefrontCore/views/partials/shop-favicon.hbs b/backend/src/modules/storefrontCore/views/partials/shop-favicon.hbs new file mode 100644 index 0000000..1dd9924 --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/shop-favicon.hbs @@ -0,0 +1,3 @@ +{{#if faviconUrl}} + +{{/if}} diff --git a/backend/src/modules/storefrontCore/views/partials/shop-footer.hbs b/backend/src/modules/storefrontCore/views/partials/shop-footer.hbs new file mode 100644 index 0000000..a8c7268 --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/shop-footer.hbs @@ -0,0 +1,8 @@ + diff --git a/backend/src/modules/storefrontCore/views/partials/shop-head.hbs b/backend/src/modules/storefrontCore/views/partials/shop-head.hbs new file mode 100644 index 0000000..4b8f6c5 --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/shop-head.hbs @@ -0,0 +1,20 @@ + + +{{pageMeta.documentTitle}} + + + + + + +{{#if pageMeta.ogImageUrl}} + +{{/if}} +{{#if refreshSec}} + +{{/if}} + +{{> shop-favicon}} +{{#if pageMeta.productJsonLd}} + +{{/if}} diff --git a/backend/src/modules/storefrontCore/views/partials/shop-nav.hbs b/backend/src/modules/storefrontCore/views/partials/shop-nav.hbs new file mode 100644 index 0000000..d8f301d --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/shop-nav.hbs @@ -0,0 +1,30 @@ +
    +
    +
    + {{#if logoUrl}} + + + + {{else}} + {{shopName}} + {{/if}} + + +
    + + {{#if fiatPerXmr}} +
    1 XMR = {{fiatPerXmr}} {{shopFiatCurrency}}
    + {{/if}} +
    +
    diff --git a/backend/src/modules/storefrontCore/views/partials/summary-grand-totals.hbs b/backend/src/modules/storefrontCore/views/partials/summary-grand-totals.hbs new file mode 100644 index 0000000..e73a62e --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/summary-grand-totals.hbs @@ -0,0 +1,30 @@ +
    + {{> summary-totals-lines + subtotalFiat=subtotalFiat + discountTotalFiat=discountTotalFiat + totalFiat=totalFiat + discounts=discounts + }} + + {{#if hasShipping}} +
    + Shipping + + {{#if isShippingQuoted}} + {{shippingCostFiat}} + {{shopFiatCurrency}} + {{else}} + — + {{/if}} + +
    + +
    + Grand total + + {{#if grandTotalFiat}}{{grandTotalFiat}}{{else}}{{totalFiat}}{{/if}} + {{shopFiatCurrency}} + +
    + {{/if}} +
    diff --git a/backend/src/modules/storefrontCore/views/partials/summary-totals-lines.hbs b/backend/src/modules/storefrontCore/views/partials/summary-totals-lines.hbs new file mode 100644 index 0000000..c27ec25 --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/summary-totals-lines.hbs @@ -0,0 +1,30 @@ +
    + Subtotal + {{subtotalFiat}} {{shopFiatCurrency}} +
    + +{{#if discounts}} +

    Discounts

    + + {{#each discounts}} +
    +
    + {{code}} + −{{amountFiat}} {{../shopFiatCurrency}} +
    +
    + {{/each}} +{{/if}} + +
    + Total discounts + + {{#if (gt discountTotalFiat 0)}}−{{/if}}{{discountTotalFiat}} + {{shopFiatCurrency}} + +
    + +
    + Total + {{totalFiat}} {{shopFiatCurrency}} +
    diff --git a/backend/src/modules/storefrontCore/views/partials/summary-totals.hbs b/backend/src/modules/storefrontCore/views/partials/summary-totals.hbs new file mode 100644 index 0000000..d8ea36a --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/summary-totals.hbs @@ -0,0 +1,8 @@ +
    + {{> summary-totals-lines + subtotalFiat=subtotalFiat + discountTotalFiat=discountTotalFiat + totalFiat=totalFiat + discounts=discounts + }} +
    diff --git a/backend/src/modules/storefrontCore/views/partials/theme-switcher.hbs b/backend/src/modules/storefrontCore/views/partials/theme-switcher.hbs new file mode 100644 index 0000000..fde9ba2 --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/theme-switcher.hbs @@ -0,0 +1,19 @@ +
    + Theme +
    + + +
    +
    diff --git a/backend/src/modules/storefrontCore/views/partials/variant-image-gallery.hbs b/backend/src/modules/storefrontCore/views/partials/variant-image-gallery.hbs new file mode 100644 index 0000000..9b34a38 --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/variant-image-gallery.hbs @@ -0,0 +1,25 @@ +{{#if selectedImageUrl}} + +{{/if}} diff --git a/backend/src/modules/storefrontCore/views/partials/variant-picker-link.hbs b/backend/src/modules/storefrontCore/views/partials/variant-picker-link.hbs new file mode 100644 index 0000000..a9ede6c --- /dev/null +++ b/backend/src/modules/storefrontCore/views/partials/variant-picker-link.hbs @@ -0,0 +1,13 @@ + + {{#if thumbnailUrl}} + {{#if title}}{{title}}{{else}}Variant{{/if}} + {{else}} + no image + {{/if}} + diff --git a/backend/src/modules/storefrontCore/views/product-detail.hbs b/backend/src/modules/storefrontCore/views/product-detail.hbs new file mode 100644 index 0000000..2855841 --- /dev/null +++ b/backend/src/modules/storefrontCore/views/product-detail.hbs @@ -0,0 +1,46 @@ +{{> page-back-link href=backHref label=backLabel}} + +

    {{product.selectedVariant.productTitle}} - {{product.selectedVariant.title}}

    + +{{#if product.variants.length}} +
    +
    + {{#with product.selectedVariant}} + {{> variant-image-gallery productTitle=productTitle}} + {{/with}} +
    + + + + {{#if product.descriptionHtml}} +
    {{{product.descriptionHtml}}}
    + {{/if}} +
    +{{else}} +

    Out of stock

    +{{/if}} diff --git a/backend/src/modules/storefrontCore/views/products-index.hbs b/backend/src/modules/storefrontCore/views/products-index.hbs new file mode 100644 index 0000000..05378d2 --- /dev/null +++ b/backend/src/modules/storefrontCore/views/products-index.hbs @@ -0,0 +1,11 @@ +

    {{title}}

    + +{{#if products}} +
      + {{#each products}} + {{> product-card}} + {{/each}} +
    +{{else}} +

    No products yet.

    +{{/if}} diff --git a/backend/src/modules/storefrontCore/views/shop-error.hbs b/backend/src/modules/storefrontCore/views/shop-error.hbs new file mode 100644 index 0000000..35da8f3 --- /dev/null +++ b/backend/src/modules/storefrontCore/views/shop-error.hbs @@ -0,0 +1,3 @@ +

    {{#if errorStatusCode}}{{errorStatusCode}}{{else}}Error{{/if}}

    +

    {{errorMessage}}

    +{{> page-back-link href='/' label='Shop'}} diff --git a/backend/src/modules/storefrontOrder/StorefrontOrderModule.ts b/backend/src/modules/storefrontOrder/StorefrontOrderModule.ts new file mode 100644 index 0000000..096eb20 --- /dev/null +++ b/backend/src/modules/storefrontOrder/StorefrontOrderModule.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { EncryptionModule } from '../encryption/EncryptionModule'; +import { OrderModule } from '../order/OrderModule'; +import { StorefrontCoreModule } from '../storefrontCore/StorefrontCoreModule'; +import { StorefrontOrderController } from './controllers/StorefrontOrderController'; +import { StorefrontOrderService } from './services/StorefrontOrderService'; +import { StorefrontOrderViewService } from './services/StorefrontOrderViewService'; + +@Module({ + imports: [OrderModule, EncryptionModule, StorefrontCoreModule], + controllers: [StorefrontOrderController], + providers: [StorefrontOrderService, StorefrontOrderViewService] +}) +export class StorefrontOrderModule {} diff --git a/backend/src/modules/storefrontOrder/controllers/StorefrontOrderController.ts b/backend/src/modules/storefrontOrder/controllers/StorefrontOrderController.ts new file mode 100644 index 0000000..b1c378a --- /dev/null +++ b/backend/src/modules/storefrontOrder/controllers/StorefrontOrderController.ts @@ -0,0 +1,216 @@ +import { + BadRequestException, + Body, + Controller, + Get, + HttpStatus, + NotFoundException, + Param, + ParseUUIDPipe, + Post, + Query, + Req, + Res, + StreamableFile, + UseFilters, + UseGuards +} from '@nestjs/common'; +import { create as createContentDisposition } from 'content-disposition'; +import type { Request, Response } from 'express'; +import { Readable } from 'node:stream'; +import { StorefrontExceptionFilter } from '../../storefrontCore/filters/StorefrontExceptionFilter'; +import { OrderAuthGuard } from '../../storefrontCore/guards/OrderAuthGuard'; +import { StorefrontFeedbackCookieService } from '../../storefrontCore/services/StorefrontFeedbackCookieService'; +import { StorefrontOrderAuthCookieService } from '../../storefrontCore/services/StorefrontOrderAuthCookieService'; +import { StorefrontShopViewService } from '../../storefrontCore/services/StorefrontShopViewService'; +import { OrderChatService } from '../../order/services/OrderChatService'; +import { OrderMessageSender } from '../../order/types/OrderMessageSender'; +import { resolveStorefrontOrderRefreshAnchor } from '../utils/resolveStorefrontOrderRefreshAnchor'; +import { safeInternalShopRedirectPath } from '../../../utils/safeInternalShopRedirectPath'; +import { StorefrontOrderService } from '../services/StorefrontOrderService'; +import { StorefrontOrderViewService } from '../services/StorefrontOrderViewService'; +import { SubmitOrderMessageDto } from '../../order/dto/SubmitOrderMessageDto'; +import { Throttle } from '@nestjs/throttler'; +import { throttleProfiles } from '../../../config/throttleProfiles'; + +@Controller() +@UseFilters(StorefrontExceptionFilter) +export class StorefrontOrderController { + constructor( + private readonly orderService: StorefrontOrderService, + private readonly orderViewService: StorefrontOrderViewService, + private readonly orderAuthCookieService: StorefrontOrderAuthCookieService, + private readonly orderChatService: OrderChatService, + private readonly shopViewService: StorefrontShopViewService, + private readonly feedbackCookieService: StorefrontFeedbackCookieService + ) {} + + @Get('shop/check-order') + async checkOrderPage(@Req() req: Request, @Res() res: Response) { + const shopLocals = await this.shopViewService.buildShopRenderLocals(req, res, { + title: 'Check your order', + metaDescription: 'Look up your order status at {shopName}.' + }); + + return res.render('order-check', { + ...shopLocals + }); + } + + @Post('shop/check-order') + @Throttle(throttleProfiles.checkOrder) + async checkOrder(@Req() req: Request, @Res() res: Response, @Body('token') token: string): Promise { + const trimmedToken = token?.trim(); + + if (!trimmedToken) { + this.feedbackCookieService.setFeedback(req, res, { + type: 'error', + text: 'Enter your order access token.' + }); + + res.redirect(HttpStatus.FOUND, '/shop/check-order'); + + return; + } + + const order = await this.orderService.findByAccessToken(trimmedToken); + + if (!order) { + this.feedbackCookieService.setFeedback(req, res, { + type: 'error', + text: 'We could not find that order.' + }); + + res.redirect(HttpStatus.FOUND, '/shop/check-order'); + + return; + } + + this.orderAuthCookieService.grantAccess(req, res, order.id); + + res.redirect(HttpStatus.FOUND, `/shop/order/${order.id}`); + } + + @Post('shop/order/:orderId/logout') + logoutFromOrder(@Param('orderId', ParseUUIDPipe) orderId: string, @Req() req: Request, @Res() res: Response): void { + this.orderAuthCookieService.revokeAccess(req, res, orderId); + + const refererPath = safeInternalShopRedirectPath(req, '/'); + const loggedOutOrderPath = `/shop/order/${orderId}`; + + const redirectPath = + refererPath === loggedOutOrderPath || refererPath.startsWith(`${loggedOutOrderPath}/`) ? '/' : refererPath; + + this.feedbackCookieService.setFeedback(req, res, { + type: 'success', + text: 'You have been logged out of this order.' + }); + + res.redirect(HttpStatus.FOUND, redirectPath); + } + + @Get('shop/order/:orderId/refresh') + @UseGuards(OrderAuthGuard) + refreshOrderSection( + @Param('orderId', ParseUUIDPipe) orderId: string, + @Query('section') section: string, + @Res() res: Response + ): void { + const anchor = resolveStorefrontOrderRefreshAnchor(section); + + if (!anchor) { + throw new BadRequestException('Invalid refresh section'); + } + + res.redirect(HttpStatus.FOUND, `/shop/order/${orderId}#${anchor}`); + } + + @Get('shop/order/:orderId') + @UseGuards(OrderAuthGuard) + async orderPage(@Param('orderId', ParseUUIDPipe) orderId: string, @Req() req: Request, @Res() res: Response) { + const orderRecord = await this.orderService.findByIdForStorefrontView(orderId); + + if (!orderRecord) { + throw new NotFoundException('We could not find that order.'); + } + + res.set('Cache-Control', 'private, no-store'); + + const [shopLocals, order] = await Promise.all([ + this.shopViewService.buildShopRenderLocals(req, res, { + title: 'Your order', + metaDescription: 'View your order at {shopName}.' + }), + this.orderViewService.toOrderView(orderRecord) + ]); + + return res.render('order', { + order, + ...shopLocals + }); + } + + @Post('shop/order/:orderId/messages') + @UseGuards(OrderAuthGuard) + async postOrderMessage( + @Param('orderId', ParseUUIDPipe) orderId: string, + @Body() { body }: SubmitOrderMessageDto, + @Res() res: Response + ): Promise { + await this.orderChatService.createMessage(orderId, OrderMessageSender.Buyer, body); + + res.redirect(HttpStatus.FOUND, `/shop/order/${orderId}#order-chat`); + } + + @Post('shop/order/:orderId/messages/:messageId/delete') + @UseGuards(OrderAuthGuard) + async deleteOrderMessage( + @Param('orderId', ParseUUIDPipe) orderId: string, + @Param('messageId', ParseUUIDPipe) messageId: string, + @Res() res: Response + ): Promise { + await this.orderChatService.deleteMessage(orderId, messageId, OrderMessageSender.Buyer); + + res.redirect(HttpStatus.FOUND, `/shop/order/${orderId}#order-chat`); + } + + @Post('shop/order/:orderId/confirm-access-token-saved') + @UseGuards(OrderAuthGuard) + async confirmAccessTokenSaved( + @Param('orderId', ParseUUIDPipe) orderId: string, + @Req() req: Request, + @Res() res: Response + ): Promise { + await this.orderService.confirmAccessTokenSaved(orderId); + + this.feedbackCookieService.setFeedback(req, res, { + type: 'success', + text: 'Your access token has been hidden successfully.' + }); + + res.redirect(HttpStatus.FOUND, `/shop/order/${orderId}`); + } + + @Get('shop/order/:orderId/attachments/:attachmentId/download') + @UseGuards(OrderAuthGuard) + async downloadFulfillmentAttachment( + @Param('orderId', ParseUUIDPipe) orderId: string, + @Param('attachmentId', ParseUUIDPipe) attachmentId: string, + @Res({ passthrough: true }) res: Response + ): Promise { + const attachment = await this.orderService.getFulfillmentAttachmentForDownload(orderId, attachmentId); + + if (!attachment) { + throw new NotFoundException(); + } + + res.set({ + 'Content-Type': attachment.mimeType, + 'Content-Disposition': createContentDisposition(attachment.originalFilename, { type: 'attachment' }), + 'Content-Length': attachment.sizeBytes, + 'Cache-Control': 'private, no-store' + }); + + return new StreamableFile(Readable.from(attachment.content)); + } +} diff --git a/backend/src/modules/storefrontOrder/services/StorefrontOrderService.spec.ts b/backend/src/modules/storefrontOrder/services/StorefrontOrderService.spec.ts new file mode 100644 index 0000000..96f1a10 --- /dev/null +++ b/backend/src/modules/storefrontOrder/services/StorefrontOrderService.spec.ts @@ -0,0 +1,213 @@ +import { NotFoundException } from '@nestjs/common'; +import type { Repository } from 'typeorm'; +import type { EncryptionService } from '../../encryption/services/EncryptionService'; +import type { Order } from '../../order/entities/Order'; +import type { OrderLineAutoFulfillmentItemAttachment } from '../../order/entities/OrderLineAutoFulfillmentItemAttachment'; +import type { OrderAccessTokenService } from '../../order/services/OrderAccessTokenService'; +import { PaymentMethod } from '../../payment/types/PaymentMethod'; +import { StorefrontOrderService } from './StorefrontOrderService'; + +const buildPaidInvoice = () => ({ + paymentMethod: PaymentMethod.Xmr, + expectedTotalAtomic: '1000', + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + moneroDetails: { requiredConfirmations: 1 }, + payments: [{ amountAtomic: '1000', confirmations: 1 }] +}); + +describe('StorefrontOrderService', () => { + let service: StorefrontOrderService; + let orderRepo: { + findOne: jest.Mock; + update: jest.Mock; + createQueryBuilder: jest.Mock; + }; + let orderQueryBuilder: { + leftJoinAndSelect: jest.Mock; + addSelect: jest.Mock; + orderBy: jest.Mock; + addOrderBy: jest.Mock; + where: jest.Mock; + getOne: jest.Mock; + }; + let fulfillmentAttachmentRepo: { + createQueryBuilder: jest.Mock; + }; + let attachmentQueryBuilder: { + addSelect: jest.Mock; + innerJoin: jest.Mock; + where: jest.Mock; + andWhere: jest.Mock; + getOne: jest.Mock; + }; + let accessTokenService: { + hashLookup: jest.Mock; + }; + let encryptionService: { + decryptFileAtPath: jest.Mock; + }; + + beforeEach(() => { + orderQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null) + }; + + attachmentQueryBuilder = { + addSelect: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null) + }; + + orderRepo = { + findOne: jest.fn().mockResolvedValue(null), + update: jest.fn().mockResolvedValue(undefined), + createQueryBuilder: jest.fn().mockReturnValue(orderQueryBuilder) + }; + + fulfillmentAttachmentRepo = { + createQueryBuilder: jest.fn().mockReturnValue(attachmentQueryBuilder) + }; + + accessTokenService = { + hashLookup: jest.fn().mockReturnValue('lookup-hash') + }; + + encryptionService = { + decryptFileAtPath: jest.fn().mockResolvedValue(Buffer.from('file-bytes')) + }; + + service = new StorefrontOrderService( + orderRepo as unknown as Repository, + fulfillmentAttachmentRepo as unknown as Repository, + accessTokenService as unknown as OrderAccessTokenService, + encryptionService as unknown as EncryptionService + ); + }); + + describe('confirmAccessTokenSaved', () => { + it('throws when the order cannot be found', async () => { + await expect(service.confirmAccessTokenSaved('order-1')).rejects.toThrow( + new NotFoundException('We could not find that order.') + ); + }); + + it('marks the order as confirmed when it has not been confirmed yet', async () => { + orderRepo.findOne.mockResolvedValue({ + id: 'order-1', + accessTokenSavedConfirmedAt: null + }); + + await service.confirmAccessTokenSaved('order-1'); + + expect(orderRepo.update).toHaveBeenCalledWith('order-1', { + accessTokenSavedConfirmedAt: expect.any(Date) + }); + }); + + it('does not update an order that was already confirmed', async () => { + orderRepo.findOne.mockResolvedValue({ + id: 'order-1', + accessTokenSavedConfirmedAt: new Date('2026-01-01T00:00:00.000Z') + }); + + await service.confirmAccessTokenSaved('order-1'); + + expect(orderRepo.update).not.toHaveBeenCalled(); + }); + }); + + describe('order lookup', () => { + it('hashes the access token and loads the order lookup fields', async () => { + orderRepo.findOne.mockResolvedValue({ id: 'order-1', accessToken: 'encrypted' }); + + await expect(service.findByAccessToken('raw-token')).resolves.toEqual({ + id: 'order-1', + accessToken: 'encrypted' + }); + + expect(accessTokenService.hashLookup).toHaveBeenCalledWith('raw-token'); + expect(orderRepo.findOne).toHaveBeenCalledWith({ + where: { accessTokenLookup: 'lookup-hash' }, + select: { id: true, accessToken: true } + }); + }); + + it('loads an order id for storefront existence checks', async () => { + orderRepo.findOne.mockResolvedValue({ id: 'order-1' }); + + await expect(service.findById('order-1')).resolves.toEqual({ id: 'order-1' }); + }); + + it('loads a storefront order detail query by id', async () => { + orderQueryBuilder.getOne.mockResolvedValue({ id: 'order-1' }); + + await expect(service.findByIdForStorefrontView('order-1')).resolves.toEqual({ id: 'order-1' }); + + expect(orderRepo.createQueryBuilder).toHaveBeenCalledWith('order'); + expect(orderQueryBuilder.where).toHaveBeenCalledWith('order.id = :orderId', { orderId: 'order-1' }); + }); + }); + + describe('getFulfillmentAttachmentForDownload', () => { + it('returns null when the order or checkout invoice is missing', async () => { + await expect(service.getFulfillmentAttachmentForDownload('order-1', 'attachment-1')).resolves.toBeNull(); + + expect(fulfillmentAttachmentRepo.createQueryBuilder).not.toHaveBeenCalled(); + }); + + it('returns null when checkout is not paid and confirmed', async () => { + orderQueryBuilder.getOne.mockResolvedValue({ + id: 'order-1', + checkoutInvoice: { + ...buildPaidInvoice(), + payments: [{ amountAtomic: '100', confirmations: 1 }] + } + }); + + await expect(service.getFulfillmentAttachmentForDownload('order-1', 'attachment-1')).resolves.toBeNull(); + + expect(fulfillmentAttachmentRepo.createQueryBuilder).not.toHaveBeenCalled(); + }); + + it('returns null when the attachment does not belong to the order', async () => { + orderQueryBuilder.getOne.mockResolvedValue({ + id: 'order-1', + checkoutInvoice: buildPaidInvoice() + }); + attachmentQueryBuilder.getOne.mockResolvedValue(null); + + await expect(service.getFulfillmentAttachmentForDownload('order-1', 'attachment-1')).resolves.toBeNull(); + + expect(encryptionService.decryptFileAtPath).not.toHaveBeenCalled(); + }); + + it('returns decrypted attachment content when checkout is paid and confirmed', async () => { + orderQueryBuilder.getOne.mockResolvedValue({ + id: 'order-1', + checkoutInvoice: buildPaidInvoice() + }); + attachmentQueryBuilder.getOne.mockResolvedValue({ + storageKey: 'stock/file.pdf', + mimeType: 'application/pdf', + originalFilename: 'file.pdf', + sizeBytes: 1024 + }); + + await expect(service.getFulfillmentAttachmentForDownload('order-1', 'attachment-1')).resolves.toEqual({ + content: Buffer.from('file-bytes'), + mimeType: 'application/pdf', + originalFilename: 'file.pdf', + sizeBytes: 1024 + }); + + expect(encryptionService.decryptFileAtPath).toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/modules/storefrontOrder/services/StorefrontOrderService.ts b/backend/src/modules/storefrontOrder/services/StorefrontOrderService.ts new file mode 100644 index 0000000..39cbc77 --- /dev/null +++ b/backend/src/modules/storefrontOrder/services/StorefrontOrderService.ts @@ -0,0 +1,110 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { resolveDigitalStockAttachmentPath } from '../../../config/uploadPaths'; +import { deriveInvoiceState } from '../../../utils/invoice/deriveInvoiceState'; +import { Order } from '../../order/entities/Order'; +import { EncryptionService } from '../../encryption/services/EncryptionService'; +import { OrderAccessTokenService } from '../../order/services/OrderAccessTokenService'; +import { OrderLineAutoFulfillmentItemAttachment } from '../../order/entities/OrderLineAutoFulfillmentItemAttachment'; +import type { FulfillmentAttachmentDownload } from '../types/FulfillmentAttachmentDownload'; +import { createOrderDetailQuery } from '../../../utils/order/createOrderDetailQuery'; + +@Injectable() +export class StorefrontOrderService { + constructor( + @InjectRepository(Order) + private readonly orderRepo: Repository, + @InjectRepository(OrderLineAutoFulfillmentItemAttachment) + private readonly fulfillmentAttachmentRepo: Repository, + private readonly accessTokenService: OrderAccessTokenService, + private readonly encryptionService: EncryptionService + ) {} + + async findByAccessToken(token: string): Promise { + const accessTokenLookup = this.accessTokenService.hashLookup(token); + + return this.orderRepo.findOne({ + where: { accessTokenLookup }, + select: { id: true, accessToken: true } + }); + } + + async findById(orderId: string): Promise { + return this.orderRepo.findOne({ + where: { id: orderId }, + select: { id: true } + }); + } + + async findByIdForStorefrontView(orderId: string): Promise { + const orderDetailQuery = createOrderDetailQuery(this.orderRepo, orderId); + + return orderDetailQuery.getOne(); + } + + async confirmAccessTokenSaved(orderId: string): Promise { + const order = await this.orderRepo.findOne({ + where: { id: orderId }, + select: { id: true, accessTokenSavedConfirmedAt: true } + }); + + if (!order) { + throw new NotFoundException('We could not find that order.'); + } + + if (order.accessTokenSavedConfirmedAt !== null) { + return; + } + + await this.orderRepo.update(order.id, { accessTokenSavedConfirmedAt: new Date() }); + } + + async getFulfillmentAttachmentForDownload( + orderId: string, + attachmentId: string + ): Promise { + const order = await this.orderRepo + .createQueryBuilder('order') + .leftJoinAndSelect('order.checkoutInvoice', 'checkoutInvoice') + .leftJoinAndSelect('checkoutInvoice.moneroDetails', 'moneroDetails') + .leftJoinAndSelect('checkoutInvoice.payments', 'payment') + .where('order.id = :orderId', { orderId }) + .getOne(); + + if (!order?.checkoutInvoice) { + return null; + } + + const { isPaidAndConfirmed } = deriveInvoiceState(order.checkoutInvoice); + + if (!isPaidAndConfirmed) { + return null; + } + + const attachment = await this.fulfillmentAttachmentRepo + .createQueryBuilder('attachment') + .addSelect('attachment.storageKey') + .innerJoin('attachment.item', 'item') + .innerJoin('item.orderLine', 'line') + .innerJoin('line.order', 'order') + .where('attachment.id = :attachmentId', { attachmentId }) + .andWhere('order.id = :orderId', { orderId }) + .getOne(); + + if (!attachment) { + return null; + } + + const path = resolveDigitalStockAttachmentPath(attachment.storageKey); + + const content = await this.encryptionService.decryptFileAtPath(path); + + return { + content, + mimeType: attachment.mimeType, + originalFilename: attachment.originalFilename, + sizeBytes: attachment.sizeBytes + }; + } +} diff --git a/backend/src/modules/storefrontOrder/services/StorefrontOrderViewService.spec.ts b/backend/src/modules/storefrontOrder/services/StorefrontOrderViewService.spec.ts new file mode 100644 index 0000000..be3c091 --- /dev/null +++ b/backend/src/modules/storefrontOrder/services/StorefrontOrderViewService.spec.ts @@ -0,0 +1,387 @@ +import { ConfigService } from '@nestjs/config'; +import { XMR_ATOMIC_PER_XMR } from '../../../consts/xmrAtomicPerXmr'; +import type { Order } from '../../order/entities/Order'; +import type { OrderLine } from '../../order/entities/OrderLine'; +import { PaymentMethod } from '../../payment/types/PaymentMethod'; +import { InvoiceReason } from '../../payment/types/InvoiceReason'; +import { DeliveryMode } from '../../product/types/DeliveryMode'; +import { ManualLineFulfillmentStatus } from '../../order/types/ManualLineFulfillmentStatus'; +import { OrderFailureReason } from '../../order/types/OrderFailureReason'; +import { OrderMessageSender } from '../../order/types/OrderMessageSender'; +import { OrderStatus } from '../../order/types/OrderStatus'; +import type { OrderMessage } from '../../order/entities/OrderMessage'; +import type { EncryptionService } from '../../encryption/services/EncryptionService'; +import type { OrderAccessTokenService } from '../../order/services/OrderAccessTokenService'; +import type { TestOrder, TestOrderLine } from '../types/StorefrontOrderViewServiceTestTypes'; +import { StorefrontOrderViewService } from './StorefrontOrderViewService'; +import { StorefrontOrderRefreshSection } from '../../../consts/storefrontOrderRefreshSection'; + +const oneXmrAtomic = XMR_ATOMIC_PER_XMR.toString(); +const orderId = '11111111-1111-4111-8111-111111111111'; + +const buildAutoLine = (overrides: TestOrderLine = {}): OrderLine => + ({ + id: 'line-auto-1', + productId: 'product-1', + variantId: 'variant-1', + productTitle: 'Product', + variantTitle: 'Variant', + thumbnailUrl: null, + qty: 1, + unitPriceFiat: 10, + lineSubtotalFiat: 10, + deliveryMode: DeliveryMode.Auto, + autoFulfillmentItems: [ + { + id: 'auto-item-1', + sortOrder: 0, + contentSnapshot: 'license-key', + attachments: [ + { + id: 'attachment-1', + originalFilename: 'file.pdf' + } + ] + } + ], + manualFulfillment: null, + ...overrides + }) as OrderLine; + +const buildOrder = (overrides: TestOrder = {}): Order => { + const defaultLine = buildAutoLine(); + + return { + id: orderId, + accessToken: 'encrypted-access-token', + accessTokenSavedConfirmedAt: null, + failureReason: null, + lines: [defaultLine], + discounts: [], + messages: [], + checkoutInvoice: { + id: 'checkout-invoice-1', + reason: InvoiceReason.Checkout, + paymentMethod: PaymentMethod.Xmr, + amountFiat: 10, + fiatCurrency: 'USD', + paymentAddress: '4checkout', + expectedTotalAtomic: oneXmrAtomic, + moneroDetails: { + paymentAddressIndex: 1, + fiatPerXmrAtCreation: 150, + requiredConfirmations: 1 + }, + payments: [ + { + txHash: 'abc', + amountAtomic: oneXmrAtomic, + confirmations: 3, + createdAt: new Date('2026-01-01T12:00:00Z') + } + ] + }, + shippingInvoice: null, + ...overrides + } as Order; +}; + +describe('StorefrontOrderViewService', () => { + let configService: { get: jest.Mock }; + let encryptionService: { decryptPlaintext: jest.Mock }; + let accessTokenService: { decryptStored: jest.Mock }; + let service: StorefrontOrderViewService; + + beforeEach(() => { + configService = { + get: jest.fn((key: string) => { + if (key === 'app.validation') { + return { orderMessageMaxLength: 2000 }; + } + + if (key === 'order') { + return { dataRetentionDays: 30 }; + } + + return {}; + }) + }; + + encryptionService = { + decryptPlaintext: jest.fn((serialized: string) => { + if (serialized === 'license-key') { + return 'license-key'; + } + + return 'Hello buyer'; + }) + }; + + accessTokenService = { + decryptStored: jest.fn(() => 'decrypted-access-token') + }; + + service = new StorefrontOrderViewService( + configService as unknown as ConfigService, + encryptionService as unknown as EncryptionService, + accessTokenService as unknown as OrderAccessTokenService + ); + }); + + it('shows auto delivery content when checkout confirmations are met', async () => { + const order = buildOrder(); + const view = await service.toOrderView(order); + + expect(view.lines[0].canShowAutoDelivery).toBe(true); + expect(view.lines[0].digitalDeliveries[0].content).toBe('license-key'); + }); + + it('hides auto delivery content when checkout confirmations are not met', async () => { + const order = buildOrder({ + checkoutInvoice: { + id: 'checkout-invoice-1', + reason: InvoiceReason.Checkout, + paymentMethod: PaymentMethod.Xmr, + amountFiat: 10, + paymentAddress: '4checkout', + expectedTotalAtomic: oneXmrAtomic, + moneroDetails: { + paymentAddressIndex: 1, + fiatPerXmrAtCreation: 150, + requiredConfirmations: 1 + }, + payments: [ + { + txHash: 'abc', + amountAtomic: oneXmrAtomic, + confirmations: 0, + createdAt: new Date('2026-01-01T12:00:00Z') + } + ] + } + }); + + const view = await service.toOrderView(order); + + expect(view.lines[0].canShowAutoDelivery).toBe(false); + expect(view.lines[0].digitalDeliveries).toEqual([]); + }); + + it('exposes shipping quote state from quotedAt and shipping invoice', async () => { + const line = buildAutoLine({ + deliveryMode: DeliveryMode.Manual, + autoFulfillmentItems: [] + }); + const order = buildOrder({ + lines: [line], + shippingInvoice: { + id: 'shipping-invoice-1', + reason: InvoiceReason.Shipping, + paymentMethod: PaymentMethod.Xmr, + amountFiat: 5, + paymentAddress: '4shipping', + expectedTotalAtomic: oneXmrAtomic, + expiresAt: new Date('2026-01-02T12:00:00Z'), + moneroDetails: { + paymentAddressIndex: 2, + fiatPerXmrAtCreation: 150, + requiredConfirmations: 1 + }, + payments: [] + }, + quotedAt: new Date('2026-01-01T12:00:00Z') + }); + + const view = await service.toOrderView(order); + + expect(view.isShippingQuoted).toBe(true); + expect(view.totals.shippingCostFiat).toBe(5); + expect(view.totals.grandTotalFiat).toBe(15); + expect(view.shippingInvoice).toMatchObject({ + cryptoCurrency: 'XMR', + paymentAddress: '4shipping' + }); + }); + + it('exposes free shipping quote without a shipping invoice', async () => { + const line = buildAutoLine({ + deliveryMode: DeliveryMode.Manual, + autoFulfillmentItems: [] + }); + const order = buildOrder({ + lines: [line], + quotedAt: new Date('2026-01-01T12:00:00Z') + }); + + const view = await service.toOrderView(order); + + expect(view.isShippingQuoted).toBe(true); + expect(view.totals.shippingCostFiat).toBe(0); + expect(view.totals.grandTotalFiat).toBe(10); + expect(view.shippingInvoice).toBeNull(); + }); + + it('leaves checkout invoice null when the order has no checkout invoice', async () => { + const order = buildOrder({ + checkoutInvoice: null + }); + const view = await service.toOrderView(order); + + expect(view.checkoutInvoice).toBeNull(); + }); + + it('includes manual fulfillment status on manual lines', async () => { + const line = buildAutoLine({ + deliveryMode: DeliveryMode.Manual, + autoFulfillmentItems: [], + manualFulfillment: { + status: ManualLineFulfillmentStatus.Pending + } + }); + const order = buildOrder({ + lines: [line] + }); + + const view = await service.toOrderView(order); + + expect(view.lines[0].manualFulfillment).toEqual({ + status: ManualLineFulfillmentStatus.Pending + }); + }); + + it('maps discounts and line product links', async () => { + const line = buildAutoLine({ + productId: 'product-42', + variantId: 'variant-99' + }); + const order = buildOrder({ + lines: [line], + discounts: [{ code: 'SAVE10', amountFiat: 2 }] + }); + + const view = await service.toOrderView(order); + + expect(view.discounts).toEqual([{ code: 'SAVE10', amountFiat: 2 }]); + expect(view.lines[0].linkHref).toBe('/shop/products/product-42/variants/variant-99'); + }); + + it('sets hasManualLines for mixed delivery modes', async () => { + const autoLine = buildAutoLine({ id: 'line-auto' }); + const manualLine = buildAutoLine({ + id: 'line-manual', + deliveryMode: DeliveryMode.Manual, + autoFulfillmentItems: [] + }); + const order = buildOrder({ + lines: [autoLine, manualLine] + }); + + const view = await service.toOrderView(order); + + expect(view.hasManualLines).toBe(true); + }); + + it('maps chat messages with decrypted body and sender flags', async () => { + const buyerMessage = { + id: 'message-buyer', + sender: OrderMessageSender.Buyer, + body: 'encrypted-buyer', + createdAt: new Date('2026-01-01T12:00:00Z') + } as OrderMessage; + const staffMessage = { + id: 'message-staff', + sender: OrderMessageSender.Staff, + body: 'encrypted-staff', + createdAt: new Date('2026-01-01T13:00:00Z') + } as OrderMessage; + const messages = [buyerMessage, staffMessage] as Order['messages']; + const order = buildOrder({ + messages + }); + + const view = await service.toOrderView(order); + + expect(encryptionService.decryptPlaintext).toHaveBeenCalledWith('encrypted-buyer'); + expect(encryptionService.decryptPlaintext).toHaveBeenCalledWith('encrypted-staff'); + expect(view.chat.messageMaxLength).toBe(2000); + expect(view.chat.messages[0]).toMatchObject({ + body: 'Hello buyer', + isBuyer: false, + deleteAction: null + }); + expect(view.chat.messages[1]).toMatchObject({ + body: 'Hello buyer', + isBuyer: true, + deleteAction: `/shop/order/${orderId}/messages/message-buyer/delete` + }); + }); + + it('maps auto delivery attachment download links', async () => { + const order = buildOrder(); + + const view = await service.toOrderView(order); + + expect(view.lines[0].digitalDeliveries[0].attachments[0].downloadHref).toBe( + `/shop/order/${orderId}/attachments/attachment-1/download` + ); + }); + + it('returns null manualFulfillment when manual line has no fulfillment record', async () => { + const line = buildAutoLine({ + deliveryMode: DeliveryMode.Manual, + autoFulfillmentItems: [], + manualFulfillment: null + }); + const order = buildOrder({ + lines: [line] + }); + + const view = await service.toOrderView(order); + + expect(view.lines[0].manualFulfillment).toBeNull(); + }); + + it('exposes unfulfillable status when the order has a failure reason', async () => { + const order = buildOrder({ + failureReason: OrderFailureReason.StockUnavailable + }); + + const view = await service.toOrderView(order); + + expect(view.status).toBe(OrderStatus.Unfulfillable); + }); + + it('maps order page context fields from the order entity', async () => { + const order = buildOrder({ + accessTokenSavedConfirmedAt: null + }); + + const view = await service.toOrderView(order); + + expect(view.chatRefreshHref).toBe( + `/shop/order/${orderId}/refresh?section=${StorefrontOrderRefreshSection.Chat}` + ); + expect(view.checkoutPaymentRefreshHref).toBe( + `/shop/order/${orderId}/refresh?section=${StorefrontOrderRefreshSection.CheckoutPayment}` + ); + expect(view.shippingPaymentRefreshHref).toBe( + `/shop/order/${orderId}/refresh?section=${StorefrontOrderRefreshSection.ShippingPayment}` + ); + expect(view.confirmAccessTokenSavedAction).toBe(`/shop/order/${orderId}/confirm-access-token-saved`); + expect(view.postMessageAction).toBe(`/shop/order/${orderId}/messages`); + expect(view.accessToken).toBe('decrypted-access-token'); + expect(view.showAccessTokenBanner).toBe(true); + expect(view.dataRetentionDays).toBe(30); + expect(accessTokenService.decryptStored).toHaveBeenCalledWith('encrypted-access-token'); + }); + + it('hides the access token banner after the buyer confirms it was saved', async () => { + const order = buildOrder({ + accessTokenSavedConfirmedAt: new Date('2026-01-01T12:00:00Z') + }); + + const view = await service.toOrderView(order); + + expect(view.showAccessTokenBanner).toBe(false); + }); +}); diff --git a/backend/src/modules/storefrontOrder/services/StorefrontOrderViewService.ts b/backend/src/modules/storefrontOrder/services/StorefrontOrderViewService.ts new file mode 100644 index 0000000..6e312a6 --- /dev/null +++ b/backend/src/modules/storefrontOrder/services/StorefrontOrderViewService.ts @@ -0,0 +1,163 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { Config } from '../../../types/Config'; +import { formatRelativeTimeAgo } from '../../../utils/formatRelativeTimeAgo'; +import { deriveOrderTotals } from '../../../utils/order/deriveOrderTotals'; +import { deriveOrderState } from '../../../utils/order/deriveOrderState'; +import { isSet } from '../../../utils/isSet'; +import { OrderAccessTokenService } from '../../order/services/OrderAccessTokenService'; +import type { Order } from '../../order/entities/Order'; +import type { OrderLine } from '../../order/entities/OrderLine'; +import type { OrderLineAutoFulfillmentItem } from '../../order/entities/OrderLineAutoFulfillmentItem'; +import type { OrderLineManualFulfillment } from '../../order/entities/OrderLineManualFulfillment'; +import type { OrderMessage } from '../../order/entities/OrderMessage'; +import { OrderMessageSender } from '../../order/types/OrderMessageSender'; +import { toStorefrontDiscountView } from '../../../utils/storefront/toStorefrontDiscountView'; +import { toStorefrontInvoiceView } from '../../../utils/invoice/toStorefrontInvoiceView'; +import type { InvoiceState } from '../../../utils/invoice/types/InvoiceState'; +import { EncryptionService } from '../../encryption/services/EncryptionService'; +import { DeliveryMode } from '../../product/types/DeliveryMode'; +import type { StorefrontOrderDigitalDeliveryView } from '../types/StorefrontOrderDigitalDeliveryView'; +import type { StorefrontOrderLineView } from '../types/StorefrontOrderLineView'; +import type { StorefrontOrderManualFulfillmentView } from '../types/StorefrontOrderManualFulfillmentView'; +import type { StorefrontOrderMessageView } from '../types/StorefrontOrderMessageView'; +import type { StorefrontOrderChatView } from '../types/StorefrontOrderChatView'; +import type { StorefrontOrderView } from '../types/StorefrontOrderView'; +import { StorefrontOrderRefreshSection } from '../../../consts/storefrontOrderRefreshSection'; +import { buildStorefrontOrderRefreshHref } from '../utils/buildStorefrontOrderRefreshHref'; + +@Injectable() +export class StorefrontOrderViewService { + constructor( + private readonly configService: ConfigService, + private readonly encryptionService: EncryptionService, + private readonly accessTokenService: OrderAccessTokenService + ) {} + + async toOrderView(order: Order): Promise { + const orderId = order.id; + + const { checkoutInvoiceState, status } = deriveOrderState(order); + + const lines = order.lines ?? []; + const discounts = order.discounts ?? []; + + const hasManualLines = lines.some(line => line.deliveryMode === DeliveryMode.Manual); + + const { dataRetentionDays } = this.configService.get('order') as Config['order']; + + const [checkoutInvoice, shippingInvoice] = await Promise.all([ + order.checkoutInvoice ? toStorefrontInvoiceView(order.checkoutInvoice) : Promise.resolve(null), + order.shippingInvoice ? toStorefrontInvoiceView(order.shippingInvoice) : Promise.resolve(null) + ]); + + return { + chatRefreshHref: buildStorefrontOrderRefreshHref(orderId, StorefrontOrderRefreshSection.Chat), + checkoutPaymentRefreshHref: buildStorefrontOrderRefreshHref( + orderId, + StorefrontOrderRefreshSection.CheckoutPayment + ), + shippingPaymentRefreshHref: buildStorefrontOrderRefreshHref( + orderId, + StorefrontOrderRefreshSection.ShippingPayment + ), + confirmAccessTokenSavedAction: `/shop/order/${orderId}/confirm-access-token-saved`, + postMessageAction: `/shop/order/${orderId}/messages`, + accessToken: this.accessTokenService.decryptStored(order.accessToken), + showAccessTokenBanner: order.accessTokenSavedConfirmedAt === null, + status, + hasManualLines, + totals: deriveOrderTotals(order), + lines: lines.map(line => this.toLineView(line, checkoutInvoiceState, orderId)), + discounts: discounts.map(toStorefrontDiscountView), + checkoutInvoice, + shippingInvoice, + isShippingQuoted: isSet(order.quotedAt), + chat: this.toChatView(order.messages ?? [], orderId), + dataRetentionDays + }; + } + + private toLineView( + line: OrderLine, + checkoutInvoiceState: InvoiceState | null, + orderId: string + ): StorefrontOrderLineView { + const canShowAutoDelivery = + (line.deliveryMode === DeliveryMode.Auto && checkoutInvoiceState?.isPaidAndConfirmed) ?? false; + + return { + linkHref: `/shop/products/${line.productId}/variants/${line.variantId}`, + thumbnailUrl: line.thumbnailUrl, + productTitle: line.productTitle, + variantTitle: line.variantTitle, + qty: line.qty, + unitPriceFiat: line.unitPriceFiat, + lineSubtotalFiat: line.lineSubtotalFiat, + deliveryMode: line.deliveryMode, + canShowAutoDelivery, + digitalDeliveries: canShowAutoDelivery ? this.toAutoDeliveryViews(line, orderId) : [], + manualFulfillment: + line.deliveryMode === DeliveryMode.Manual ? this.toManualFulfillmentView(line.manualFulfillment) : null + }; + } + + private toChatView(messages: OrderMessage[], orderId: string): StorefrontOrderChatView { + const { orderMessageMaxLength } = this.configService.get('app.validation') as Config['app']['validation']; + + return { + messages: [...messages].reverse().map(message => this.toMessageView(message, orderId)), + messageMaxLength: orderMessageMaxLength + }; + } + + private toAutoDeliveryViews(line: OrderLine, orderId: string): StorefrontOrderDigitalDeliveryView[] { + if (line.deliveryMode !== DeliveryMode.Auto) { + return []; + } + + const items = line.autoFulfillmentItems ?? []; + + if (items.length === 0) { + return []; + } + + return items.map(item => this.toAutoDeliveryItemView(item, orderId)); + } + + private toManualFulfillmentView( + manualFulfillment: OrderLineManualFulfillment | null | undefined + ): StorefrontOrderManualFulfillmentView | null { + if (!manualFulfillment) { + return null; + } + + return { + status: manualFulfillment.status + }; + } + + private toMessageView(message: OrderMessage, orderId: string): StorefrontOrderMessageView { + const isBuyer = message.sender === OrderMessageSender.Buyer; + + return { + body: this.encryptionService.decryptPlaintext(message.body), + sentAtLabel: formatRelativeTimeAgo(message.createdAt), + isBuyer, + deleteAction: isBuyer ? `/shop/order/${orderId}/messages/${message.id}/delete` : null + }; + } + + private toAutoDeliveryItemView( + { contentSnapshot, attachments }: OrderLineAutoFulfillmentItem, + orderId: string + ): StorefrontOrderDigitalDeliveryView { + return { + content: this.encryptionService.decryptPlaintext(contentSnapshot), + attachments: attachments.map(attachment => ({ + originalFilename: attachment.originalFilename, + downloadHref: `/shop/order/${orderId}/attachments/${attachment.id}/download` + })) + }; + } +} diff --git a/backend/src/modules/storefrontOrder/types/FulfillmentAttachmentDownload.ts b/backend/src/modules/storefrontOrder/types/FulfillmentAttachmentDownload.ts new file mode 100644 index 0000000..abd24cf --- /dev/null +++ b/backend/src/modules/storefrontOrder/types/FulfillmentAttachmentDownload.ts @@ -0,0 +1,6 @@ +export type FulfillmentAttachmentDownload = { + content: Buffer; + mimeType: string; + originalFilename: string; + sizeBytes: number; +}; diff --git a/backend/src/modules/storefrontOrder/types/StorefrontOrderChatView.ts b/backend/src/modules/storefrontOrder/types/StorefrontOrderChatView.ts new file mode 100644 index 0000000..ea362ee --- /dev/null +++ b/backend/src/modules/storefrontOrder/types/StorefrontOrderChatView.ts @@ -0,0 +1,6 @@ +import type { StorefrontOrderMessageView } from './StorefrontOrderMessageView'; + +export type StorefrontOrderChatView = { + messages: StorefrontOrderMessageView[]; + messageMaxLength: number; +}; diff --git a/backend/src/modules/storefrontOrder/types/StorefrontOrderDigitalDeliveryAttachmentView.ts b/backend/src/modules/storefrontOrder/types/StorefrontOrderDigitalDeliveryAttachmentView.ts new file mode 100644 index 0000000..2371dce --- /dev/null +++ b/backend/src/modules/storefrontOrder/types/StorefrontOrderDigitalDeliveryAttachmentView.ts @@ -0,0 +1,4 @@ +export type StorefrontOrderDigitalDeliveryAttachmentView = { + originalFilename: string; + downloadHref: string; +}; diff --git a/backend/src/modules/storefrontOrder/types/StorefrontOrderDigitalDeliveryView.ts b/backend/src/modules/storefrontOrder/types/StorefrontOrderDigitalDeliveryView.ts new file mode 100644 index 0000000..ed22154 --- /dev/null +++ b/backend/src/modules/storefrontOrder/types/StorefrontOrderDigitalDeliveryView.ts @@ -0,0 +1,6 @@ +import type { StorefrontOrderDigitalDeliveryAttachmentView } from './StorefrontOrderDigitalDeliveryAttachmentView'; + +export type StorefrontOrderDigitalDeliveryView = { + content: string; + attachments: StorefrontOrderDigitalDeliveryAttachmentView[]; +}; diff --git a/backend/src/modules/storefrontOrder/types/StorefrontOrderLineView.ts b/backend/src/modules/storefrontOrder/types/StorefrontOrderLineView.ts new file mode 100644 index 0000000..178182c --- /dev/null +++ b/backend/src/modules/storefrontOrder/types/StorefrontOrderLineView.ts @@ -0,0 +1,17 @@ +import type { DeliveryMode } from '../../product/types/DeliveryMode'; +import type { StorefrontOrderDigitalDeliveryView } from './StorefrontOrderDigitalDeliveryView'; +import type { StorefrontOrderManualFulfillmentView } from './StorefrontOrderManualFulfillmentView'; + +export type StorefrontOrderLineView = { + linkHref: string | null; + thumbnailUrl: string | null; + productTitle: string; + variantTitle: string; + qty: number; + unitPriceFiat: number; + lineSubtotalFiat: number; + deliveryMode: DeliveryMode; + canShowAutoDelivery: boolean; + digitalDeliveries: StorefrontOrderDigitalDeliveryView[]; + manualFulfillment: StorefrontOrderManualFulfillmentView | null; +}; diff --git a/backend/src/modules/storefrontOrder/types/StorefrontOrderManualFulfillmentView.ts b/backend/src/modules/storefrontOrder/types/StorefrontOrderManualFulfillmentView.ts new file mode 100644 index 0000000..978b34d --- /dev/null +++ b/backend/src/modules/storefrontOrder/types/StorefrontOrderManualFulfillmentView.ts @@ -0,0 +1,5 @@ +import type { ManualLineFulfillmentStatus } from '../../order/types/ManualLineFulfillmentStatus'; + +export type StorefrontOrderManualFulfillmentView = { + status: ManualLineFulfillmentStatus; +}; diff --git a/backend/src/modules/storefrontOrder/types/StorefrontOrderMessageView.ts b/backend/src/modules/storefrontOrder/types/StorefrontOrderMessageView.ts new file mode 100644 index 0000000..f9fb21a --- /dev/null +++ b/backend/src/modules/storefrontOrder/types/StorefrontOrderMessageView.ts @@ -0,0 +1,6 @@ +export type StorefrontOrderMessageView = { + body: string; + sentAtLabel: string; + isBuyer: boolean; + deleteAction: string | null; +}; diff --git a/backend/src/modules/storefrontOrder/types/StorefrontOrderView.ts b/backend/src/modules/storefrontOrder/types/StorefrontOrderView.ts new file mode 100644 index 0000000..a119300 --- /dev/null +++ b/backend/src/modules/storefrontOrder/types/StorefrontOrderView.ts @@ -0,0 +1,26 @@ +import type { OrderStatus } from '../../order/types/OrderStatus'; +import type { StorefrontInvoiceView } from '../../storefrontCore/types/StorefrontInvoiceView'; +import type { StorefrontDiscountView } from '../../storefrontCore/types/StorefrontDiscountView'; +import type { StorefrontOrderLineView } from './StorefrontOrderLineView'; +import type { StorefrontOrderChatView } from './StorefrontOrderChatView'; +import type { OrderTotals } from '../../../utils/order/types/OrderTotals'; + +export type StorefrontOrderView = { + chatRefreshHref: string; + checkoutPaymentRefreshHref: string; + shippingPaymentRefreshHref: string; + confirmAccessTokenSavedAction: string; + postMessageAction: string; + accessToken: string; + showAccessTokenBanner: boolean; + status: OrderStatus; + hasManualLines: boolean; + totals: OrderTotals; + lines: StorefrontOrderLineView[]; + discounts: StorefrontDiscountView[]; + checkoutInvoice: StorefrontInvoiceView | null; + shippingInvoice: StorefrontInvoiceView | null; + isShippingQuoted: boolean; + chat: StorefrontOrderChatView; + dataRetentionDays: number; +}; diff --git a/backend/src/modules/storefrontOrder/types/StorefrontOrderViewServiceTestTypes.ts b/backend/src/modules/storefrontOrder/types/StorefrontOrderViewServiceTestTypes.ts new file mode 100644 index 0000000..c8900c5 --- /dev/null +++ b/backend/src/modules/storefrontOrder/types/StorefrontOrderViewServiceTestTypes.ts @@ -0,0 +1,26 @@ +import type { Order } from '../../order/entities/Order'; +import type { OrderLine } from '../../order/entities/OrderLine'; +import type { Invoice } from '../../payment/entities/Invoice'; +import type { InvoicePayment } from '../../payment/entities/InvoicePayment'; +import type { OrderDiscount } from '../../order/entities/OrderDiscount'; + +export type TestInvoicePayment = Partial< + Pick +>; + +export type TestInvoice = Partial> & { + moneroDetails?: Partial> | null; + payments?: TestInvoicePayment[]; +}; + +export type TestOrderLine = Partial> & { + autoFulfillmentItems?: Partial[]; + manualFulfillment?: Partial> | null; +}; + +export type TestOrder = Partial> & { + lines?: TestOrderLine[]; + discounts?: Partial[]; + checkoutInvoice?: TestInvoice | null; + shippingInvoice?: TestInvoice | null; +}; diff --git a/backend/src/modules/storefrontOrder/utils/buildStorefrontOrderRefreshHref.spec.ts b/backend/src/modules/storefrontOrder/utils/buildStorefrontOrderRefreshHref.spec.ts new file mode 100644 index 0000000..7a98027 --- /dev/null +++ b/backend/src/modules/storefrontOrder/utils/buildStorefrontOrderRefreshHref.spec.ts @@ -0,0 +1,10 @@ +import { StorefrontOrderRefreshSection } from '../../../consts/storefrontOrderRefreshSection'; +import { buildStorefrontOrderRefreshHref } from './buildStorefrontOrderRefreshHref'; + +describe('buildStorefrontOrderRefreshHref', () => { + it('builds refresh hrefs for an order', () => { + expect(buildStorefrontOrderRefreshHref('order-1', StorefrontOrderRefreshSection.ShippingPayment)).toBe( + '/shop/order/order-1/refresh?section=shipping-payment' + ); + }); +}); diff --git a/backend/src/modules/storefrontOrder/utils/buildStorefrontOrderRefreshHref.ts b/backend/src/modules/storefrontOrder/utils/buildStorefrontOrderRefreshHref.ts new file mode 100644 index 0000000..20b7eec --- /dev/null +++ b/backend/src/modules/storefrontOrder/utils/buildStorefrontOrderRefreshHref.ts @@ -0,0 +1,4 @@ +import type { StorefrontOrderRefreshSection } from '../../../types/storefront/StorefrontOrderRefreshSection'; + +export const buildStorefrontOrderRefreshHref = (orderId: string, section: StorefrontOrderRefreshSection): string => + `/shop/order/${orderId}/refresh?section=${section}`; diff --git a/backend/src/modules/storefrontOrder/utils/resolveStorefrontOrderRefreshAnchor.spec.ts b/backend/src/modules/storefrontOrder/utils/resolveStorefrontOrderRefreshAnchor.spec.ts new file mode 100644 index 0000000..882f6a6 --- /dev/null +++ b/backend/src/modules/storefrontOrder/utils/resolveStorefrontOrderRefreshAnchor.spec.ts @@ -0,0 +1,21 @@ +import { StorefrontOrderPageAnchor } from '../../../consts/storefrontOrderPageAnchor'; +import { StorefrontOrderRefreshSection } from '../../../consts/storefrontOrderRefreshSection'; +import { resolveStorefrontOrderRefreshAnchor } from './resolveStorefrontOrderRefreshAnchor'; + +describe('resolveStorefrontOrderRefreshAnchor', () => { + it('maps refresh sections to page anchors', () => { + expect(resolveStorefrontOrderRefreshAnchor(StorefrontOrderRefreshSection.Chat)).toBe( + StorefrontOrderPageAnchor.Chat + ); + expect(resolveStorefrontOrderRefreshAnchor(StorefrontOrderRefreshSection.CheckoutPayment)).toBe( + StorefrontOrderPageAnchor.CheckoutPayment + ); + expect(resolveStorefrontOrderRefreshAnchor(StorefrontOrderRefreshSection.ShippingPayment)).toBe( + StorefrontOrderPageAnchor.ShippingPayment + ); + }); + + it('returns null for unknown sections', () => { + expect(resolveStorefrontOrderRefreshAnchor('unknown')).toBeNull(); + }); +}); diff --git a/backend/src/modules/storefrontOrder/utils/resolveStorefrontOrderRefreshAnchor.ts b/backend/src/modules/storefrontOrder/utils/resolveStorefrontOrderRefreshAnchor.ts new file mode 100644 index 0000000..cfddd3e --- /dev/null +++ b/backend/src/modules/storefrontOrder/utils/resolveStorefrontOrderRefreshAnchor.ts @@ -0,0 +1,12 @@ +import { StorefrontOrderPageAnchor } from '../../../consts/storefrontOrderPageAnchor'; +import { StorefrontOrderRefreshSection } from '../../../consts/storefrontOrderRefreshSection'; +import type { StorefrontOrderRefreshSection as StorefrontOrderRefreshSectionType } from '../../../types/storefront/StorefrontOrderRefreshSection'; + +const refreshSectionToAnchor: Record = { + [StorefrontOrderRefreshSection.Chat]: StorefrontOrderPageAnchor.Chat, + [StorefrontOrderRefreshSection.CheckoutPayment]: StorefrontOrderPageAnchor.CheckoutPayment, + [StorefrontOrderRefreshSection.ShippingPayment]: StorefrontOrderPageAnchor.ShippingPayment +}; + +export const resolveStorefrontOrderRefreshAnchor = (section: string): string | null => + refreshSectionToAnchor[section as StorefrontOrderRefreshSectionType] ?? null; diff --git a/backend/src/modules/storefrontProduct/StorefrontProductModule.ts b/backend/src/modules/storefrontProduct/StorefrontProductModule.ts new file mode 100644 index 0000000..f575697 --- /dev/null +++ b/backend/src/modules/storefrontProduct/StorefrontProductModule.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { ProductsModule } from '../product/ProductsModule'; +import { StorefrontCoreModule } from '../storefrontCore/StorefrontCoreModule'; +import { StorefrontProductsController } from './controllers/StorefrontProductsController'; +import { StorefrontProductsService } from './services/StorefrontProductsService'; +import { StorefrontProductViewService } from './services/StorefrontProductViewService'; + +@Module({ + imports: [ProductsModule, StorefrontCoreModule], + controllers: [StorefrontProductsController], + providers: [StorefrontProductsService, StorefrontProductViewService], + exports: [StorefrontProductsService] +}) +export class StorefrontProductModule {} diff --git a/backend/src/modules/storefrontProduct/controllers/StorefrontProductsController.ts b/backend/src/modules/storefrontProduct/controllers/StorefrontProductsController.ts new file mode 100644 index 0000000..e8269f4 --- /dev/null +++ b/backend/src/modules/storefrontProduct/controllers/StorefrontProductsController.ts @@ -0,0 +1,121 @@ +import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Query, Req, Res, UseFilters } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { CategoriesService } from '../../product/services/CategoriesService'; +import { StorefrontExceptionFilter } from '../../storefrontCore/filters/StorefrontExceptionFilter'; +import { StorefrontShopViewService } from '../../storefrontCore/services/StorefrontShopViewService'; +import { getQtyByVariantIdFromCart } from '../../../utils/cart/getQtyByVariantIdFromCart'; +import { StorefrontProductsService } from '../services/StorefrontProductsService'; +import { StorefrontProductViewService } from '../services/StorefrontProductViewService'; +import { StorefrontCartCookieService } from '../../storefrontCore/services/StorefrontCartCookieService'; +@Controller() +@UseFilters(StorefrontExceptionFilter) +export class StorefrontProductsController { + constructor( + private readonly productsService: StorefrontProductsService, + private readonly productViewService: StorefrontProductViewService, + private readonly categoriesService: CategoriesService, + private readonly shopViewService: StorefrontShopViewService, + private readonly cartCookieService: StorefrontCartCookieService + ) {} + + @Get('/') + async productsIndex(@Req() req: Request, @Res() res: Response) { + return this.renderProductsIndex(req, res); + } + + @Get('shop/categories/:id') + async productsByCategory(@Param('id', ParseUUIDPipe) id: string, @Req() req: Request, @Res() res: Response) { + const category = await this.categoriesService.findOne(id); + + return this.renderProductsIndex(req, res, { + categoryId: category.id, + title: category.name + }); + } + + private async renderProductsIndex( + req: Request, + res: Response, + options: { categoryId?: string; title?: string } = {} + ) { + const { categoryId, title = 'Shop' } = options; + + const cart = this.cartCookieService.getCart(req, res); + + const qtyByVariant = getQtyByVariantIdFromCart(cart); + + const [categories, products] = await Promise.all([ + this.categoriesService.findAll(), + this.productsService.listStorefrontProducts(qtyByVariant, categoryId) + ]); + + const shopLocals = await this.shopViewService.buildShopRenderLocals(req, res, { + title, + metaDescription: 'Browse products at {shopName}.' + }); + + const productsView = this.productViewService.toProductsIndexView(products, req.query, categoryId); + const categoriesView = this.productViewService.toCategoriesView(categories); + + return res.render('products-index', { + products: productsView, + categories: categoriesView, + activeCategoryId: categoryId ?? null, + ...shopLocals + }); + } + + @Get('shop/products/:id/variants/:variantId') + async productDetail( + @Param('id', ParseUUIDPipe) id: string, + @Param('variantId', ParseUUIDPipe) variantId: string, + @Query('category', new ParseUUIDPipe({ optional: true })) categoryId: string | undefined, + @Query('selectedImage', new ParseUUIDPipe({ optional: true })) selectedImageId: string | undefined, + @Req() req: Request, + @Res() res: Response + ) { + const cart = this.cartCookieService.getCart(req, res); + + const qtyByVariant = getQtyByVariantIdFromCart(cart); + + const [product, backLink] = await Promise.all([ + this.productsService.getStorefrontProduct(id, qtyByVariant), + this.productViewService.resolveProductBackLink(categoryId) + ]); + + const variant = product.variants.find(v => v.id === variantId); + + if (!variant) { + throw new NotFoundException('We could not find that product variant.'); + } + + const productView = this.productViewService.toProductView(product, { + page: 'detail', + categoryId, + selectedImageId, + selectedVariantId: variant.id + }); + + const selectedVariant = productView.selectedVariant!; + const productTitle = `${selectedVariant.productTitle} - ${selectedVariant.title}`; + const imageUrl = selectedVariant.selectedImageUrl ?? selectedVariant.thumbnailUrl; + + const shopLocals = await this.shopViewService.buildShopRenderLocals(req, res, { + title: productTitle, + metaDescription: `Buy ${productTitle} at {shopName}.`, + ogType: 'product', + ogImageUrl: imageUrl, + product: { + imageUrl, + price: selectedVariant.price, + inStock: selectedVariant.stockForSession > 0 + } + }); + + return res.render('product-detail', { + product: productView, + ...backLink, + ...shopLocals + }); + } +} diff --git a/backend/src/modules/storefrontProduct/services/StorefrontProductViewService.spec.ts b/backend/src/modules/storefrontProduct/services/StorefrontProductViewService.spec.ts new file mode 100644 index 0000000..d358ecb --- /dev/null +++ b/backend/src/modules/storefrontProduct/services/StorefrontProductViewService.spec.ts @@ -0,0 +1,22 @@ +import type { CategoriesService } from '../../product/services/CategoriesService'; +import { StorefrontProductViewService } from './StorefrontProductViewService'; + +describe('StorefrontProductViewService', () => { + let service: StorefrontProductViewService; + + beforeEach(() => { + service = new StorefrontProductViewService({} as unknown as CategoriesService); + }); + + it('maps categories to storefront nav items', () => { + expect( + service.toCategoriesView([ + { id: 'category-1', name: 'Digital', sortOrder: 0 } as never, + { id: 'category-2', name: 'Physical', sortOrder: 1 } as never + ]) + ).toEqual([ + { id: 'category-1', name: 'Digital', href: '/shop/categories/category-1' }, + { id: 'category-2', name: 'Physical', href: '/shop/categories/category-2' } + ]); + }); +}); diff --git a/backend/src/modules/storefrontProduct/services/StorefrontProductViewService.ts b/backend/src/modules/storefrontProduct/services/StorefrontProductViewService.ts new file mode 100644 index 0000000..bb3f57f --- /dev/null +++ b/backend/src/modules/storefrontProduct/services/StorefrontProductViewService.ts @@ -0,0 +1,247 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import type { Request } from 'express'; +import type { Category } from '../../product/entities/Category'; +import { CategoriesService } from '../../product/services/CategoriesService'; +import type { IndexVariantSelection } from '../types/IndexVariantSelection'; +import type { ProductViewContext } from '../types/ProductViewContext'; +import type { StorefrontCategoryNavItem } from '../types/StorefrontCategoryNavItem'; +import type { StorefrontProduct } from '../types/StorefrontProduct'; +import type { StorefrontProductView } from '../types/StorefrontProductView'; +import type { StorefrontSelectedVariantView } from '../types/StorefrontSelectedVariantView'; +import { StorefrontVariantView } from '../types/StorefrontVariantView'; +import type { StorefrontVariantImage } from '../types/StorefrontVariantImage'; + +@Injectable() +export class StorefrontProductViewService { + private readonly productCardVariantsMax = 4; + + constructor(private readonly categoriesService: CategoriesService) {} + + toCategoriesView(categories: Category[]): StorefrontCategoryNavItem[] { + return categories.map(category => ({ + id: category.id, + name: category.name, + href: `/shop/categories/${category.id}` + })); + } + + toProductsIndexView( + products: StorefrontProduct[], + query: Request['query'], + categoryId?: string + ): StorefrontProductView[] { + const basePath = categoryId ? `/shop/categories/${categoryId}` : '/'; + const selection = this.parseIndexSelection(products, query); + + return products.map(product => { + const selectedVariantId = this.resolveProductSelectedVariantId(product, selection); + + return this.toProductView(product, { page: 'index', basePath, categoryId, selectedVariantId }); + }); + } + + private parseIndexSelection(products: StorefrontProduct[], query: Request['query']): IndexVariantSelection | null { + const raw = query.sel; + + if (!raw) { + return null; + } + + const value = Array.isArray(raw) ? raw[0] : raw; + + if (typeof value !== 'string') { + return null; + } + + const separatorIndex = value.indexOf(':'); + + if (separatorIndex === -1) { + return null; + } + + const productId = value.slice(0, separatorIndex); + const variantId = value.slice(separatorIndex + 1); + + if (!productId || !variantId) { + return null; + } + + const product = products.find(entry => entry.id === productId); + + if (!product) { + return null; + } + + const variant = product.variants.find(entry => entry.id === variantId); + + if (!variant) { + return null; + } + + return { productId, variantId }; + } + + private resolveProductSelectedVariantId( + product: StorefrontProduct, + selection: IndexVariantSelection | null + ): string { + if (selection?.productId === product.id) { + return selection.variantId; + } + + return product.variants[0].id; + } + + toProductView(product: StorefrontProduct, context: ProductViewContext): StorefrontProductView { + if (context.page === 'index') { + const variants: StorefrontVariantView[] = product.variants.map(variant => ({ + ...variant, + isSelected: variant.id === context.selectedVariantId, + selectionHref: this.buildIndexUrl(context.basePath, product, variant.id) + })); + + return { + ...product, + variants, + cardVariants: this.buildCardVariants(variants, context.selectedVariantId), + hiddenVariantCount: Math.max(0, variants.length - this.productCardVariantsMax), + selectedVariant: this.buildSelectedVariantView(product, context) + }; + } + + const variants: StorefrontVariantView[] = product.variants.map(variant => ({ + ...variant, + isSelected: variant.id === context.selectedVariantId, + detailHref: this.buildDetailUrl(product.id, variant.id, context.categoryId) + })); + + return { + ...product, + variants, + selectedVariant: this.buildSelectedVariantView(product, context) + }; + } + + private buildCardVariants(variants: StorefrontVariantView[], selectedVariantId: string): StorefrontVariantView[] { + const max = this.productCardVariantsMax; + + if (variants.length <= max) { + return variants; + } + + const first = variants.slice(0, max); + + if (first.some(variant => variant.id === selectedVariantId)) { + return first; + } + + const selected = variants.find(variant => variant.id === selectedVariantId); + + if (!selected) { + return first; + } + + return [...variants.filter(variant => variant.id !== selectedVariantId).slice(0, max - 1), selected]; + } + + private buildSelectedVariantView( + product: StorefrontProduct, + context: ProductViewContext + ): StorefrontSelectedVariantView | undefined { + const variant = product.variants.find(entry => entry.id === context.selectedVariantId); + + if (!variant) { + return undefined; + } + + const detailHref = this.buildDetailUrl(product.id, variant.id, context.categoryId); + + const base: StorefrontSelectedVariantView = { + ...variant, + detailHref + }; + + if (context.page === 'index') { + return base; + } + + const defaultImageId = this.resolveDefaultImageId(variant); + + let activeImage: StorefrontVariantImage | undefined; + + if (context.selectedImageId) { + activeImage = variant.images.find(image => image.id === context.selectedImageId); + } + + if (!activeImage) { + activeImage = variant.images.find(image => image.id === defaultImageId); + } + + const activeImageId = activeImage?.id; + + return { + ...base, + selectedImageUrl: activeImage?.url ?? variant.thumbnailUrl, + galleryImages: variant.images.map(image => ({ + ...image, + isSelected: image.id === activeImageId, + selectionHref: this.buildDetailUrl(product.id, variant.id, context.categoryId, image.id, defaultImageId) + })) + }; + } + + private resolveDefaultImageId(variant: StorefrontProduct['variants'][number]): string | null { + const thumbnail = variant.images.find(image => image.isThumbnail); + + return thumbnail?.id ?? variant.images[0]?.id ?? null; + } + + private buildDetailUrl( + productId: string, + variantId: string, + categoryId?: string, + imageId?: string | null, + defaultImageId?: string | null + ): string { + const path = `/shop/products/${productId}/variants/${variantId}`; + const params = new URLSearchParams(); + + if (categoryId) { + params.set('category', categoryId); + } + + if (imageId && imageId !== defaultImageId) { + params.set('selectedImage', imageId); + } + + const query = params.toString(); + + return query ? `${path}?${query}` : path; + } + + private buildIndexUrl(basePath: string, product: StorefrontProduct, variantId: string): string { + if (variantId === product.variants[0].id) { + return basePath; + } + + return `${basePath}?${new URLSearchParams({ sel: `${product.id}:${variantId}` }).toString()}`; + } + + async resolveProductBackLink(categoryId?: string): Promise<{ backHref: string; backLabel: string }> { + if (!categoryId) { + return { backHref: '/', backLabel: 'Shop' }; + } + + try { + const category = await this.categoriesService.findOne(categoryId); + + return { backHref: `/shop/categories/${category.id}`, backLabel: category.name }; + } catch (error) { + if (error instanceof NotFoundException) { + return { backHref: '/', backLabel: 'Shop' }; + } + + throw error; + } + } +} diff --git a/backend/src/modules/storefrontProduct/services/StorefrontProductsService.spec.ts b/backend/src/modules/storefrontProduct/services/StorefrontProductsService.spec.ts new file mode 100644 index 0000000..82c45c7 --- /dev/null +++ b/backend/src/modules/storefrontProduct/services/StorefrontProductsService.spec.ts @@ -0,0 +1,97 @@ +import { NotFoundException } from '@nestjs/common'; +import type { Repository } from 'typeorm'; +import { DeliveryMode } from '../../product/types/DeliveryMode'; +import { Product } from '../../product/entities/Product'; +import { ProductVariant } from '../../product/entities/ProductVariant'; +import type { ProductVariantsService } from '../../product/services/ProductVariantsService'; +import { StorefrontProductsService } from './StorefrontProductsService'; + +describe('StorefrontProductsService', () => { + let service: StorefrontProductsService; + let productRepo: { + find: jest.Mock; + findOne: jest.Mock; + }; + let variantRepo: { + find: jest.Mock; + }; + let productVariantsService: { + extendProductsVariants: jest.Mock; + extendVariants: jest.Mock; + }; + + beforeEach(() => { + productRepo = { + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue(null) + }; + + variantRepo = { + find: jest.fn().mockResolvedValue([]) + }; + + productVariantsService = { + extendProductsVariants: jest.fn(async products => products), + extendVariants: jest.fn(async variants => variants) + }; + + service = new StorefrontProductsService( + productRepo as unknown as Repository, + variantRepo as unknown as Repository, + productVariantsService as unknown as ProductVariantsService + ); + }); + + it('returns an empty map when no variant ids are requested', async () => { + await expect(service.getStorefrontVariantsByIds([], new Map())).resolves.toEqual(new Map()); + expect(variantRepo.find).not.toHaveBeenCalled(); + }); + + it('throws when a storefront product cannot be found', async () => { + await expect(service.getStorefrontProduct('missing-id', new Map())).rejects.toThrow( + new NotFoundException('We could not find that product.') + ); + }); + + it('maps storefront variants with session stock reserved in cart', async () => { + variantRepo.find.mockResolvedValue([ + { + id: 'variant-1', + title: 'Variant', + price: 25, + stockAvailable: 5, + images: [], + product: { + id: 'product-1', + title: 'Product', + deliveryMode: DeliveryMode.Auto, + isDraft: false + } + } + ]); + productVariantsService.extendVariants.mockResolvedValue([ + { + id: 'variant-1', + title: 'Variant', + price: 25, + stockAvailable: 5, + images: [], + product: { + id: 'product-1', + title: 'Product', + deliveryMode: DeliveryMode.Auto + } + } + ]); + + const variants = await service.getStorefrontVariantsByIds(['variant-1'], new Map([['variant-1', 2]])); + + expect(variants.get('variant-1')).toEqual( + expect.objectContaining({ + id: 'variant-1', + stockAvailable: 5, + stockForSession: 3 + }) + ); + }); +}); diff --git a/backend/src/modules/storefrontProduct/services/StorefrontProductsService.ts b/backend/src/modules/storefrontProduct/services/StorefrontProductsService.ts new file mode 100644 index 0000000..2bfc7d0 --- /dev/null +++ b/backend/src/modules/storefrontProduct/services/StorefrontProductsService.ts @@ -0,0 +1,151 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { Product } from '../../product/entities/Product'; +import { ProductVariant } from '../../product/entities/ProductVariant'; +import { VariantImage } from '../../product/entities/VariantImage'; +import { ProductVariantsService } from '../../product/services/ProductVariantsService'; +import type { ProductVariantExtended } from '../../product/types/ProductVariantExtended'; +import type { StorefrontProduct } from '../types/StorefrontProduct'; +import type { StorefrontVariant } from '../types/StorefrontVariant'; +import type { StorefrontVariantImage } from '../types/StorefrontVariantImage'; +import { resolveVariantThumbnailUrl } from '../utils/resolveVariantThumbnailUrl'; + +@Injectable() +export class StorefrontProductsService { + constructor( + @InjectRepository(Product) + private readonly productRepo: Repository, + @InjectRepository(ProductVariant) + private readonly variantRepo: Repository, + private readonly productVariantsService: ProductVariantsService + ) {} + + private toStorefrontImage(image: VariantImage): StorefrontVariantImage { + return { + id: image.id, + url: image.url, + sortOrder: image.sortOrder, + isThumbnail: image.isThumbnail + }; + } + + private toStorefrontVariant(variant: ProductVariantExtended, qtyInCart: number): StorefrontVariant { + const product = variant.product; + const images = variant.images ?? []; + + return { + id: variant.id, + productId: product.id, + productTitle: product.title, + title: variant.title, + price: variant.price, + deliveryMode: product.deliveryMode, + stockAvailable: variant.stockAvailable, + stockForSession: Math.max(0, variant.stockAvailable - qtyInCart), + thumbnailUrl: resolveVariantThumbnailUrl(images), + images: images.map(image => this.toStorefrontImage(image)) + }; + } + + private toStorefrontProduct(product: Product, variants: StorefrontVariant[]): StorefrontProduct { + return { + id: product.id, + title: product.title, + deliveryMode: product.deliveryMode, + descriptionHtml: product.descriptionHtml || null, + variants + }; + } + + async listStorefrontProducts(qtyByVariant: Map, categoryId?: string): Promise { + const products = await this.productRepo.find({ + where: categoryId ? { isDraft: false, categories: { id: categoryId } } : { isDraft: false }, + relations: ['variants', 'variants.images'], + order: { + createdAt: 'DESC', + variants: { + sortOrder: 'ASC', + createdAt: 'ASC', + images: { sortOrder: 'ASC', createdAt: 'ASC' } + } + } + }); + + const extendedProducts = await this.productVariantsService.extendProductsVariants(products); + + return extendedProducts.map(product => + this.toStorefrontProduct( + product, + product.variants.map(variant => this.toStorefrontVariant(variant, qtyByVariant.get(variant.id) ?? 0)) + ) + ); + } + + async getStorefrontProduct(id: string, qtyByVariant: Map): Promise { + const entity = await this.productRepo.findOne({ + where: { id, isDraft: false }, + relations: ['variants', 'variants.images'], + order: { + createdAt: 'DESC', + variants: { + sortOrder: 'ASC', + createdAt: 'ASC', + images: { sortOrder: 'ASC', createdAt: 'ASC' } + } + } + }); + + if (!entity) { + throw new NotFoundException('We could not find that product.'); + } + + const [product] = await this.productVariantsService.extendProductsVariants([entity]); + + return this.toStorefrontProduct( + product, + product.variants.map(variant => this.toStorefrontVariant(variant, qtyByVariant.get(variant.id) ?? 0)) + ); + } + + async getStorefrontVariant(id: string, qtyByVariant: Map): Promise { + const map = await this.getStorefrontVariantsByIds([id], qtyByVariant); + const variant = map.get(id); + + if (!variant) { + throw new NotFoundException('We could not find that product variant.'); + } + + return variant; + } + + async getStorefrontVariantsByIds( + ids: string[], + qtyByVariant: Map + ): Promise> { + if (ids.length === 0) { + return new Map(); + } + + const uniqueIds = [...new Set(ids)]; + + const variants = await this.variantRepo.find({ + where: { id: In(uniqueIds), product: { isDraft: false } }, + relations: ['product', 'images'], + order: { + sortOrder: 'ASC', + createdAt: 'ASC', + images: { sortOrder: 'ASC', createdAt: 'ASC' } + } + }); + + const extendedVariants = await this.productVariantsService.extendVariants(variants); + + return new Map( + extendedVariants.map(variant => [ + variant.id, + this.toStorefrontVariant(variant, qtyByVariant.get(variant.id) ?? 0) + ]) + ); + } +} diff --git a/backend/src/modules/storefrontProduct/types/IndexVariantSelection.ts b/backend/src/modules/storefrontProduct/types/IndexVariantSelection.ts new file mode 100644 index 0000000..ccb9d4c --- /dev/null +++ b/backend/src/modules/storefrontProduct/types/IndexVariantSelection.ts @@ -0,0 +1,4 @@ +export type IndexVariantSelection = { + productId: string; + variantId: string; +}; diff --git a/backend/src/modules/storefrontProduct/types/ProductViewContext.ts b/backend/src/modules/storefrontProduct/types/ProductViewContext.ts new file mode 100644 index 0000000..41a4a5b --- /dev/null +++ b/backend/src/modules/storefrontProduct/types/ProductViewContext.ts @@ -0,0 +1,8 @@ +type ProductViewContextBase = { + selectedVariantId: string; + categoryId?: string; +}; + +export type ProductViewContext = + | (ProductViewContextBase & { page: 'index'; basePath: string }) + | (ProductViewContextBase & { page: 'detail'; selectedImageId?: string }); diff --git a/backend/src/modules/storefrontProduct/types/StorefrontCategoryNavItem.ts b/backend/src/modules/storefrontProduct/types/StorefrontCategoryNavItem.ts new file mode 100644 index 0000000..e878ad7 --- /dev/null +++ b/backend/src/modules/storefrontProduct/types/StorefrontCategoryNavItem.ts @@ -0,0 +1,5 @@ +export type StorefrontCategoryNavItem = { + id: string; + name: string; + href: string; +}; diff --git a/backend/src/modules/storefrontProduct/types/StorefrontProduct.ts b/backend/src/modules/storefrontProduct/types/StorefrontProduct.ts new file mode 100644 index 0000000..e8bfe6d --- /dev/null +++ b/backend/src/modules/storefrontProduct/types/StorefrontProduct.ts @@ -0,0 +1,10 @@ +import type { DeliveryMode } from '../../product/types/DeliveryMode'; +import type { StorefrontVariant } from './StorefrontVariant'; + +export type StorefrontProduct = { + id: string; + title: string; + deliveryMode: DeliveryMode; + descriptionHtml: string | null; + variants: StorefrontVariant[]; +}; diff --git a/backend/src/modules/storefrontProduct/types/StorefrontProductView.ts b/backend/src/modules/storefrontProduct/types/StorefrontProductView.ts new file mode 100644 index 0000000..61d45f2 --- /dev/null +++ b/backend/src/modules/storefrontProduct/types/StorefrontProductView.ts @@ -0,0 +1,10 @@ +import type { StorefrontProduct } from './StorefrontProduct'; +import type { StorefrontSelectedVariantView } from './StorefrontSelectedVariantView'; +import type { StorefrontVariantView } from './StorefrontVariantView'; + +export type StorefrontProductView = Omit & { + variants: StorefrontVariantView[]; + cardVariants?: StorefrontVariantView[]; + hiddenVariantCount?: number; + selectedVariant?: StorefrontSelectedVariantView; +}; diff --git a/backend/src/modules/storefrontProduct/types/StorefrontSelectedVariantView.ts b/backend/src/modules/storefrontProduct/types/StorefrontSelectedVariantView.ts new file mode 100644 index 0000000..56b7693 --- /dev/null +++ b/backend/src/modules/storefrontProduct/types/StorefrontSelectedVariantView.ts @@ -0,0 +1,8 @@ +import type { StorefrontVariant } from './StorefrontVariant'; +import type { StorefrontVariantGalleryImageView } from './StorefrontVariantGalleryImageView'; + +export type StorefrontSelectedVariantView = StorefrontVariant & { + detailHref: string; + selectedImageUrl?: string | null; + galleryImages?: StorefrontVariantGalleryImageView[]; +}; diff --git a/backend/src/modules/storefrontProduct/types/StorefrontVariant.ts b/backend/src/modules/storefrontProduct/types/StorefrontVariant.ts new file mode 100644 index 0000000..f6892fa --- /dev/null +++ b/backend/src/modules/storefrontProduct/types/StorefrontVariant.ts @@ -0,0 +1,15 @@ +import type { DeliveryMode } from '../../product/types/DeliveryMode'; +import type { StorefrontVariantImage } from './StorefrontVariantImage'; + +export type StorefrontVariant = { + id: string; + productId: string; + productTitle: string; + title: string; + price: number; + deliveryMode: DeliveryMode; + stockAvailable: number; + stockForSession: number; + thumbnailUrl: string | null; + images: StorefrontVariantImage[]; +}; diff --git a/backend/src/modules/storefrontProduct/types/StorefrontVariantGalleryImageView.ts b/backend/src/modules/storefrontProduct/types/StorefrontVariantGalleryImageView.ts new file mode 100644 index 0000000..ebaba10 --- /dev/null +++ b/backend/src/modules/storefrontProduct/types/StorefrontVariantGalleryImageView.ts @@ -0,0 +1,6 @@ +import type { StorefrontVariantImage } from './StorefrontVariantImage'; + +export type StorefrontVariantGalleryImageView = StorefrontVariantImage & { + isSelected: boolean; + selectionHref: string; +}; diff --git a/backend/src/modules/storefrontProduct/types/StorefrontVariantImage.ts b/backend/src/modules/storefrontProduct/types/StorefrontVariantImage.ts new file mode 100644 index 0000000..a62aebc --- /dev/null +++ b/backend/src/modules/storefrontProduct/types/StorefrontVariantImage.ts @@ -0,0 +1,6 @@ +export type StorefrontVariantImage = { + id: string; + url: string; + sortOrder: number; + isThumbnail: boolean; +}; diff --git a/backend/src/modules/storefrontProduct/types/StorefrontVariantView.ts b/backend/src/modules/storefrontProduct/types/StorefrontVariantView.ts new file mode 100644 index 0000000..e90ca39 --- /dev/null +++ b/backend/src/modules/storefrontProduct/types/StorefrontVariantView.ts @@ -0,0 +1,7 @@ +import type { StorefrontVariant } from './StorefrontVariant'; + +export type StorefrontVariantView = StorefrontVariant & { + isSelected: boolean; + selectionHref?: string; + detailHref?: string; +}; diff --git a/backend/src/modules/storefrontProduct/utils/resolveVariantThumbnailUrl.ts b/backend/src/modules/storefrontProduct/utils/resolveVariantThumbnailUrl.ts new file mode 100644 index 0000000..7019d52 --- /dev/null +++ b/backend/src/modules/storefrontProduct/utils/resolveVariantThumbnailUrl.ts @@ -0,0 +1,19 @@ +import type { VariantImage } from '../../product/entities/VariantImage'; + +export const resolveVariantThumbnailUrl = ( + images: Pick[] +): string | null => { + if (images.length === 0) { + return null; + } + + const thumbnail = images.find(image => image.isThumbnail); + + if (thumbnail) { + return thumbnail.url; + } + + const sorted = [...images].sort((a, b) => a.sortOrder - b.sortOrder); + + return sorted[0]?.url ?? null; +}; diff --git a/backend/src/modules/xmrRate/XmrRateModule.ts b/backend/src/modules/xmrRate/XmrRateModule.ts new file mode 100644 index 0000000..9d11ca0 --- /dev/null +++ b/backend/src/modules/xmrRate/XmrRateModule.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { XmrRateService } from './services/XmrRateService'; + +@Module({ + providers: [XmrRateService], + exports: [XmrRateService] +}) +export class XmrRateModule {} diff --git a/backend/src/modules/xmrRate/dto/CoingeckoSimplePriceResponseDto.ts b/backend/src/modules/xmrRate/dto/CoingeckoSimplePriceResponseDto.ts new file mode 100644 index 0000000..d2dd690 --- /dev/null +++ b/backend/src/modules/xmrRate/dto/CoingeckoSimplePriceResponseDto.ts @@ -0,0 +1,39 @@ +import { + IsEnum, + IsNotEmpty, + IsObject, + Validate, + ValidatorConstraint, + type ValidatorConstraintInterface, + type ValidationArguments +} from 'class-validator'; +import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency'; + +@ValidatorConstraint({ name: 'coingeckoMoneroFiat' }) +class CoingeckoMoneroFiatConstraint implements ValidatorConstraintInterface { + validate(monero: Record, args: ValidationArguments): boolean { + const { shopFiatCurrency } = args.object as CoingeckoSimplePriceResponseDto; + + if (!shopFiatCurrency) { + return false; + } + + const rate = monero[shopFiatCurrency.toLowerCase()]; + + return typeof rate === 'number' && Number.isFinite(rate) && rate > 0; + } + + defaultMessage(): string { + return 'CoinGecko monero fiat rate must be a positive number'; + } +} + +export class CoingeckoSimplePriceResponseDto { + @IsEnum(ShopFiatCurrency) + shopFiatCurrency: ShopFiatCurrency; + + @IsNotEmpty() + @IsObject() + @Validate(CoingeckoMoneroFiatConstraint) + monero: Record; +} diff --git a/backend/src/modules/xmrRate/dto/KrakenTickerResponseDto.ts b/backend/src/modules/xmrRate/dto/KrakenTickerResponseDto.ts new file mode 100644 index 0000000..10468d1 --- /dev/null +++ b/backend/src/modules/xmrRate/dto/KrakenTickerResponseDto.ts @@ -0,0 +1,56 @@ +import { + ArrayMaxSize, + IsArray, + IsNotEmpty, + IsString, + Validate, + ValidatorConstraint, + type ValidatorConstraintInterface +} from 'class-validator'; +import Decimal from 'decimal.js'; + +@ValidatorConstraint({ name: 'krakenResult' }) +class KrakenResultConstraint implements ValidatorConstraintInterface { + validate(result: unknown): boolean { + if (typeof result !== 'object' || result === null) { + return false; + } + + const pair = Object.values(result as Record)[0]; + + if (typeof pair !== 'object' || pair === null) { + return false; + } + + if (!('c' in pair)) { + return false; + } + + if (!Array.isArray(pair.c) || pair.c.length < 1 || typeof pair.c[0] !== 'string') { + return false; + } + + try { + const price = new Decimal(pair.c[0]); + + return price.isFinite() && price.gt(0); + } catch { + return false; + } + } + + defaultMessage(): string { + return 'Kraken ticker last price (c[0]) must be a positive number'; + } +} + +export class KrakenTickerResponseDto { + @IsArray() + @ArrayMaxSize(0) + @IsString({ each: true }) + error: string[]; + + @IsNotEmpty() + @Validate(KrakenResultConstraint) + result: Record; +} diff --git a/backend/src/modules/xmrRate/krakenXmrPairs.ts b/backend/src/modules/xmrRate/krakenXmrPairs.ts new file mode 100644 index 0000000..667c005 --- /dev/null +++ b/backend/src/modules/xmrRate/krakenXmrPairs.ts @@ -0,0 +1,10 @@ +import { ShopFiatCurrency } from '../../types/ShopFiatCurrency'; + +export const KRAKEN_XMR_PAIR_BY_FIAT: Record = { + [ShopFiatCurrency.Usd]: 'XMRUSD', + [ShopFiatCurrency.Eur]: 'XMREUR', + [ShopFiatCurrency.Gbp]: 'XMRGBP', + [ShopFiatCurrency.Cad]: 'XMRCAD', + [ShopFiatCurrency.Aud]: 'XMRAUD', + [ShopFiatCurrency.Chf]: 'XMRCHF' +}; diff --git a/backend/src/modules/xmrRate/services/XmrRateService.spec.ts b/backend/src/modules/xmrRate/services/XmrRateService.spec.ts new file mode 100644 index 0000000..c66f2e1 --- /dev/null +++ b/backend/src/modules/xmrRate/services/XmrRateService.spec.ts @@ -0,0 +1,127 @@ +import { Logger } from '@nestjs/common'; +import type { ConfigService } from '@nestjs/config'; +import axios from 'axios'; +import { XmrRateService } from './XmrRateService'; + +jest.mock('axios'); + +const mockedAxios = axios as jest.Mocked; + +describe('XmrRateService', () => { + let service: XmrRateService; + let configService: { + get: jest.Mock; + }; + let warnLogSpy: jest.SpiedFunction; + let errorLogSpy: jest.SpiedFunction; + let logSpy: jest.SpiedFunction; + + beforeEach(() => { + warnLogSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + errorLogSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + + configService = { + get: jest.fn((key: string) => { + if (key === 'shopSettings') { + return { shopFiatCurrency: 'USD' }; + } + + if (key === 'coingecko') { + return { apiBaseUrl: 'https://coingecko.test', xmrRateFetchTimeoutMs: 5000 }; + } + + if (key === 'kraken') { + return { apiBaseUrl: 'https://kraken.test', xmrRateFetchTimeoutMs: 5000 }; + } + + return undefined; + }) + }; + + service = new XmrRateService(configService as unknown as ConfigService); + mockedAxios.get.mockReset(); + }); + + afterEach(() => { + warnLogSpy.mockRestore(); + errorLogSpy.mockRestore(); + logSpy.mockRestore(); + }); + + it('stores the CoinGecko rate when the fetch succeeds', async () => { + mockedAxios.get.mockResolvedValueOnce({ + data: { monero: { usd: 152.3456 } } + }); + + await service.fetchFiatPerXmrRate(); + + expect(mockedAxios.get).toHaveBeenCalledWith( + 'https://coingecko.test/simple/price?ids=monero&vs_currencies=usd', + { timeout: 5000 } + ); + expect(service.getLiveFiatPerXmr()).toBe(152.35); + }); + + it('falls back to Kraken when CoinGecko fails', async () => { + mockedAxios.get + .mockRejectedValueOnce(new Error('coingecko down')) + .mockResolvedValueOnce({ + data: { + error: [], + result: { + XMRUSD: { c: ['149.876'] } + } + } + }); + + await service.fetchFiatPerXmrRate(); + + expect(warnLogSpy).toHaveBeenCalled(); + expect(mockedAxios.get).toHaveBeenNthCalledWith( + 2, + 'https://kraken.test/Ticker?pair=XMRUSD', + { timeout: 5000 } + ); + expect(service.getLiveFiatPerXmr()).toBe(149.88); + }); + + it('leaves the cached rate null when both providers fail', async () => { + mockedAxios.get.mockRejectedValueOnce(new Error('coingecko down')).mockRejectedValueOnce(new Error('kraken down')); + + await service.fetchFiatPerXmrRate(); + + expect(errorLogSpy).toHaveBeenCalled(); + expect(service.getLiveFiatPerXmr()).toBeNull(); + }); + + it('falls back to Kraken when CoinGecko returns an invalid payload', async () => { + mockedAxios.get + .mockResolvedValueOnce({ data: { monero: { usd: -1 } } }) + .mockResolvedValueOnce({ + data: { + error: [], + result: { + XMRUSD: { c: ['151.11'] } + } + } + }); + + await service.fetchFiatPerXmrRate(); + + expect(mockedAxios.get).toHaveBeenCalledTimes(2); + expect(service.getLiveFiatPerXmr()).toBe(151.11); + }); + + it('keeps the previous live rate when a refresh fails after a successful fetch', async () => { + mockedAxios.get.mockResolvedValueOnce({ + data: { monero: { usd: 140 } } + }); + await service.fetchFiatPerXmrRate(); + + mockedAxios.get.mockRejectedValueOnce(new Error('coingecko down')).mockRejectedValueOnce(new Error('kraken down')); + await service.fetchFiatPerXmrRate(); + + expect(service.getLiveFiatPerXmr()).toBe(140); + }); +}); diff --git a/backend/src/modules/xmrRate/services/XmrRateService.ts b/backend/src/modules/xmrRate/services/XmrRateService.ts new file mode 100644 index 0000000..ceac404 --- /dev/null +++ b/backend/src/modules/xmrRate/services/XmrRateService.ts @@ -0,0 +1,105 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; +import axios from 'axios'; +import Decimal from 'decimal.js'; +import type { Config } from '../../../types/Config'; +import { getErrorMessage } from '../../../utils/getErrorMessage'; +import { CoingeckoSimplePriceResponseDto } from '../dto/CoingeckoSimplePriceResponseDto'; +import { KrakenTickerResponseDto } from '../dto/KrakenTickerResponseDto'; +import { KRAKEN_XMR_PAIR_BY_FIAT } from '../krakenXmrPairs'; + +@Injectable() +export class XmrRateService implements OnModuleInit { + private readonly logger = new Logger(XmrRateService.name); + private fiatPerXmr: number | null = null; + + constructor(private readonly configService: ConfigService) {} + + async onModuleInit(): Promise { + await this.fetchFiatPerXmrRate(); + } + + @Cron(CronExpression.EVERY_30_SECONDS) + async fetchFiatPerXmrRate(): Promise { + const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings']; + + try { + this.fiatPerXmr = await this.fetchFiatPerXmrFromCoingecko(); + } catch { + this.logger.warn(`CoinGecko XMR/${shopFiatCurrency} rate fetch failed, trying Kraken`); + + try { + this.fiatPerXmr = await this.fetchFiatPerXmrFromKraken(); + + this.logger.log(`Successfully fetched XMR/${shopFiatCurrency} rate from Kraken`); + } catch (error) { + this.logger.error( + `Failed to fetch XMR/${shopFiatCurrency} rate from CoinGecko and Kraken: ${getErrorMessage(error)}` + ); + } + } + } + + getLiveFiatPerXmr(): number | null { + return this.fiatPerXmr; + } + + private buildCoingeckoRateUrl(): string { + const { apiBaseUrl } = this.configService.get('coingecko') as Config['coingecko']; + const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings']; + const vs = shopFiatCurrency.toLowerCase(); + + return `${apiBaseUrl}/simple/price?ids=monero&vs_currencies=${vs}`; + } + + private buildKrakenRateUrl(): string { + const { apiBaseUrl } = this.configService.get('kraken') as Config['kraken']; + const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings']; + + const pair = KRAKEN_XMR_PAIR_BY_FIAT[shopFiatCurrency]; + + return `${apiBaseUrl}/Ticker?pair=${pair}`; + } + + private async fetchFiatPerXmrFromCoingecko(): Promise { + const { xmrRateFetchTimeoutMs } = this.configService.get('coingecko') as Config['coingecko']; + const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings']; + + const { data } = await axios.get(this.buildCoingeckoRateUrl(), { + timeout: xmrRateFetchTimeoutMs + }); + + const body = plainToInstance(CoingeckoSimplePriceResponseDto, data); + body.shopFiatCurrency = shopFiatCurrency; + + const errors = validateSync(body); + + if (errors.length > 0) { + throw new Error('CoinGecko response validation failed'); + } + + return new Decimal(body.monero[shopFiatCurrency.toLowerCase()]).toDecimalPlaces(2).toNumber(); + } + + private async fetchFiatPerXmrFromKraken(): Promise { + const { xmrRateFetchTimeoutMs } = this.configService.get('kraken') as Config['kraken']; + + const { data } = await axios.get(this.buildKrakenRateUrl(), { + timeout: xmrRateFetchTimeoutMs + }); + + const body = plainToInstance(KrakenTickerResponseDto, data); + const errors = validateSync(body); + + if (errors.length > 0) { + throw new Error('Kraken response validation failed'); + } + + const pair = Object.values(body.result)[0]; + + return new Decimal(pair.c[0]).toDecimalPlaces(2).toNumber(); + } +} diff --git a/backend/src/plugins/dayjs.ts b/backend/src/plugins/dayjs.ts new file mode 100644 index 0000000..7ae2fb5 --- /dev/null +++ b/backend/src/plugins/dayjs.ts @@ -0,0 +1,6 @@ +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; + +dayjs.extend(relativeTime); + +export default dayjs; diff --git a/backend/src/types/Config.ts b/backend/src/types/Config.ts new file mode 100644 index 0000000..cc9cc63 --- /dev/null +++ b/backend/src/types/Config.ts @@ -0,0 +1,122 @@ +import { MoneroConfirmationTier } from './MoneroConfirmationTier'; +import { MoneroWalletConfig } from './MoneroWalletConfig'; +import { NodeEnv } from './NodeEnv'; +import { PaymentMethod } from '../modules/payment/types/PaymentMethod'; +import { ShopFiatCurrency } from './ShopFiatCurrency'; +import { SimplexConfig } from './SimplexConfig'; + +export interface SignedCookieProfileConfig { + cookieName: string; + expiresInMs: number; +} + +export interface SignedCookieConfig { + jwtSecret: string; + feedback: SignedCookieProfileConfig; + cart: SignedCookieProfileConfig; + captcha: SignedCookieProfileConfig; + discount: SignedCookieProfileConfig; + error: SignedCookieProfileConfig; + checkoutSession: SignedCookieProfileConfig; + orderAuth: SignedCookieProfileConfig; + theme: SignedCookieProfileConfig; +} + +export interface CaptchaConfig { + length: number; +} + +export interface ThrottleConfig { + ttlMs: number; + limit: number; +} + +export interface AppConfig { + port: number; + nodeEnv: NodeEnv; + corsOrigins: string[]; + cmsPassword: string; + captcha: CaptchaConfig; + throttle: ThrottleConfig; + signedCookie: SignedCookieConfig; + validation: { + productTitleMaxLength: number; + categoryNameMaxLength: number; + discountCodeMaxLength: number; + variantImagesMax: number; + digitalStockAttachmentsMax: number; + shippingNoteMinLength: number; + shippingNoteMaxLength: number; + orderMessageMaxLength: number; + }; +} + +export interface ShopSettingsConfig { + shopName: string; + shopFiatCurrency: ShopFiatCurrency; + monero: { + confirmationTiers: MoneroConfirmationTier[]; + }; +} + +export interface JwtConfig { + secret: string; + expiresInMs: number; +} + +export interface PostgresConfig { + type: 'postgres'; + url: string; + host: string; + username: string; + password: string; + port: number; + database: string; + entities: string[]; + migrations: string[]; + migrationsRun: boolean; +} + +export interface MulterConfig { + allowedMimes: readonly string[]; + maxFileBytes: number; +} + +export interface CoingeckoConfig { + apiBaseUrl: string; + xmrRateFetchTimeoutMs: number; +} + +export interface KrakenConfig { + apiBaseUrl: string; + xmrRateFetchTimeoutMs: number; +} + +export interface EncryptionConfig { + keyBase64: string; +} + +export interface OrderConfig { + checkoutValidityMs: number; + shippingPaymentValidityMs: number; + checkoutStatusRefreshSec: number; + dataRetentionDays: number; +} + +export interface InvoiceConfig { + minByMethod: Record; +} + +export interface Config { + app: AppConfig; + postgres: PostgresConfig; + jwt: JwtConfig; + coingecko: CoingeckoConfig; + kraken: KrakenConfig; + encryption: EncryptionConfig; + shopSettings: ShopSettingsConfig; + order: OrderConfig; + invoice: InvoiceConfig; + moneroWallet: MoneroWalletConfig; + simplex: SimplexConfig; +} diff --git a/backend/src/types/DiskFileTypeValidatorOptions.ts b/backend/src/types/DiskFileTypeValidatorOptions.ts new file mode 100644 index 0000000..447911c --- /dev/null +++ b/backend/src/types/DiskFileTypeValidatorOptions.ts @@ -0,0 +1,3 @@ +export type DiskFileTypeValidatorOptions = { + fileType: RegExp; +}; diff --git a/backend/src/types/MoneroConfirmationTier.ts b/backend/src/types/MoneroConfirmationTier.ts new file mode 100644 index 0000000..776773d --- /dev/null +++ b/backend/src/types/MoneroConfirmationTier.ts @@ -0,0 +1,4 @@ +export interface MoneroConfirmationTier { + upToTotalFiat?: string; + minConfirmations: number; +} diff --git a/backend/src/types/MoneroNetwork.ts b/backend/src/types/MoneroNetwork.ts new file mode 100644 index 0000000..8f417da --- /dev/null +++ b/backend/src/types/MoneroNetwork.ts @@ -0,0 +1,4 @@ +export enum MoneroNetwork { + Mainnet = 'mainnet', + Stagenet = 'stagenet' +} diff --git a/backend/src/types/MoneroWalletConfig.ts b/backend/src/types/MoneroWalletConfig.ts new file mode 100644 index 0000000..4efd38e --- /dev/null +++ b/backend/src/types/MoneroWalletConfig.ts @@ -0,0 +1,10 @@ +import { MoneroNetwork } from './MoneroNetwork'; + +export interface MoneroWalletConfig { + network: MoneroNetwork; + rpcUrl: string; + daemonRpcUrl: string; + username: string; + password: string; + rpcTimeoutMs: number; +} diff --git a/backend/src/types/NodeEnv.ts b/backend/src/types/NodeEnv.ts new file mode 100644 index 0000000..db7f651 --- /dev/null +++ b/backend/src/types/NodeEnv.ts @@ -0,0 +1,4 @@ +export enum NodeEnv { + Development = 'development', + Production = 'production' +} diff --git a/backend/src/types/PaginatedResponse.ts b/backend/src/types/PaginatedResponse.ts new file mode 100644 index 0000000..4c75076 --- /dev/null +++ b/backend/src/types/PaginatedResponse.ts @@ -0,0 +1,6 @@ +export type PaginatedResponse = { + items: T[]; + total: number; + page: number; + limit: number; +}; diff --git a/backend/src/types/ShopFiatCurrency.ts b/backend/src/types/ShopFiatCurrency.ts new file mode 100644 index 0000000..c8a30aa --- /dev/null +++ b/backend/src/types/ShopFiatCurrency.ts @@ -0,0 +1,10 @@ +export enum ShopFiatCurrency { + Usd = 'USD', + Eur = 'EUR', + Gbp = 'GBP', + Cad = 'CAD', + Aud = 'AUD', + Chf = 'CHF' +} + +export const SHOP_FIAT_CURRENCY_VALUES = Object.values(ShopFiatCurrency); diff --git a/backend/src/types/ShopSurface.ts b/backend/src/types/ShopSurface.ts new file mode 100644 index 0000000..8eac5fc --- /dev/null +++ b/backend/src/types/ShopSurface.ts @@ -0,0 +1,4 @@ +export enum ShopSurface { + Clearnet = 'clearnet', + Onion = 'onion' +} diff --git a/backend/src/types/SimplexConfig.ts b/backend/src/types/SimplexConfig.ts new file mode 100644 index 0000000..82ae5c1 --- /dev/null +++ b/backend/src/types/SimplexConfig.ts @@ -0,0 +1,4 @@ +export interface SimplexConfig { + wsUrl: string; + botDisplayName: string; +} diff --git a/backend/src/types/UploadFileSource.ts b/backend/src/types/UploadFileSource.ts new file mode 100644 index 0000000..5451416 --- /dev/null +++ b/backend/src/types/UploadFileSource.ts @@ -0,0 +1 @@ +export type UploadFileSource = 'disk' | 'buffer'; diff --git a/backend/src/types/ValidatedUploadFile.ts b/backend/src/types/ValidatedUploadFile.ts new file mode 100644 index 0000000..282a3fe --- /dev/null +++ b/backend/src/types/ValidatedUploadFile.ts @@ -0,0 +1,3 @@ +export type ValidatedUploadFile = Express.Multer.File & { + detectedMimeType: string; +}; diff --git a/backend/src/types/database/InformationSchemaTableRow.ts b/backend/src/types/database/InformationSchemaTableRow.ts new file mode 100644 index 0000000..4eba891 --- /dev/null +++ b/backend/src/types/database/InformationSchemaTableRow.ts @@ -0,0 +1,3 @@ +export type InformationSchemaTableRow = { + table_name: string; +}; diff --git a/backend/src/types/database/PgEnumTypeRow.ts b/backend/src/types/database/PgEnumTypeRow.ts new file mode 100644 index 0000000..efbe1d6 --- /dev/null +++ b/backend/src/types/database/PgEnumTypeRow.ts @@ -0,0 +1,3 @@ +export type PgEnumTypeRow = { + typname: string; +}; diff --git a/backend/src/types/storefront/StorefrontOrderRefreshSection.ts b/backend/src/types/storefront/StorefrontOrderRefreshSection.ts new file mode 100644 index 0000000..f821e47 --- /dev/null +++ b/backend/src/types/storefront/StorefrontOrderRefreshSection.ts @@ -0,0 +1,4 @@ +import { StorefrontOrderRefreshSection } from '../../consts/storefrontOrderRefreshSection'; + +export type StorefrontOrderRefreshSection = + (typeof StorefrontOrderRefreshSection)[keyof typeof StorefrontOrderRefreshSection]; diff --git a/backend/src/types/validation/IsBase64Options.ts b/backend/src/types/validation/IsBase64Options.ts new file mode 100644 index 0000000..e008f27 --- /dev/null +++ b/backend/src/types/validation/IsBase64Options.ts @@ -0,0 +1,3 @@ +export interface IsBase64Options { + byteLength?: number; +} diff --git a/backend/src/types/validation/NullOrIntOptions.ts b/backend/src/types/validation/NullOrIntOptions.ts new file mode 100644 index 0000000..db251bf --- /dev/null +++ b/backend/src/types/validation/NullOrIntOptions.ts @@ -0,0 +1,3 @@ +export type NullOrIntOptions = { + min?: number; +}; diff --git a/backend/src/types/validation/NullOrNumberOptions.ts b/backend/src/types/validation/NullOrNumberOptions.ts new file mode 100644 index 0000000..5d5678f --- /dev/null +++ b/backend/src/types/validation/NullOrNumberOptions.ts @@ -0,0 +1,3 @@ +export type NullOrNumberOptions = { + min?: number; +}; diff --git a/backend/src/utils/BufferFileTypeValidator.ts b/backend/src/utils/BufferFileTypeValidator.ts new file mode 100644 index 0000000..a85e546 --- /dev/null +++ b/backend/src/utils/BufferFileTypeValidator.ts @@ -0,0 +1,33 @@ +import { FileValidator } from '@nestjs/common/pipes/file/file-validator.interface'; +import { fileTypeFromBuffer } from 'file-type'; + +import type { DiskFileTypeValidatorOptions } from '../types/DiskFileTypeValidatorOptions'; +import type { ValidatedUploadFile } from '../types/ValidatedUploadFile'; + +export class BufferFileTypeValidator extends FileValidator { + async isValid(file?: Express.Multer.File): Promise { + if (!file?.buffer) { + return false; + } + + try { + const detected = await fileTypeFromBuffer(file.buffer); + + if (!detected?.mime.match(this.validationOptions.fileType)) { + return false; + } + + (file as ValidatedUploadFile).detectedMimeType = detected.mime; + + return true; + } catch { + return false; + } + } + + buildErrorMessage(file?: Express.Multer.File): string { + const declared = file?.mimetype ? ` (declared type is ${file.mimetype})` : ''; + + return `Validation failed (file type is not allowed${declared})`; + } +} diff --git a/backend/src/utils/ColumnBigIntTransformer.ts b/backend/src/utils/ColumnBigIntTransformer.ts new file mode 100644 index 0000000..32b688f --- /dev/null +++ b/backend/src/utils/ColumnBigIntTransformer.ts @@ -0,0 +1,13 @@ +export class ColumnBigIntTransformer { + to(data: string | null): string | null { + return data; + } + + from(data: string | null): string | null { + if (data == null) { + return null; + } + + return String(data); + } +} diff --git a/backend/src/utils/ColumnNumericTransformer.ts b/backend/src/utils/ColumnNumericTransformer.ts new file mode 100644 index 0000000..5343641 --- /dev/null +++ b/backend/src/utils/ColumnNumericTransformer.ts @@ -0,0 +1,15 @@ +import Decimal from 'decimal.js'; + +export class ColumnNumericTransformer { + to(data: number | null) { + return data; + } + + from(data: string | null) { + if (data == null) { + return null; + } + + return new Decimal(data).toNumber(); + } +} diff --git a/backend/src/utils/DiskFileTypeValidator.ts b/backend/src/utils/DiskFileTypeValidator.ts new file mode 100644 index 0000000..06e1069 --- /dev/null +++ b/backend/src/utils/DiskFileTypeValidator.ts @@ -0,0 +1,38 @@ +import { FileValidator } from '@nestjs/common/pipes/file/file-validator.interface'; +import { fileTypeFromFile } from 'file-type'; + +import type { DiskFileTypeValidatorOptions } from '../types/DiskFileTypeValidatorOptions'; +import { removeFileFromDisk } from './removeFileFromDisk'; +import type { ValidatedUploadFile } from '../types/ValidatedUploadFile'; + +export class DiskFileTypeValidator extends FileValidator { + async isValid(file?: Express.Multer.File): Promise { + if (!file?.path) { + return false; + } + + try { + const detected = await fileTypeFromFile(file.path); + + if (!detected?.mime.match(this.validationOptions.fileType)) { + await removeFileFromDisk(file.path, DiskFileTypeValidator.name); + + return false; + } + + (file as ValidatedUploadFile).detectedMimeType = detected.mime; + + return true; + } catch { + await removeFileFromDisk(file.path, DiskFileTypeValidator.name); + + return false; + } + } + + buildErrorMessage(file?: Express.Multer.File): string { + const declared = file?.mimetype ? ` (declared type is ${file.mimetype})` : ''; + + return `Validation failed (file type is not allowed${declared})`; + } +} diff --git a/backend/src/utils/atomic/addAtomic.spec.ts b/backend/src/utils/atomic/addAtomic.spec.ts new file mode 100644 index 0000000..c17771e --- /dev/null +++ b/backend/src/utils/atomic/addAtomic.spec.ts @@ -0,0 +1,12 @@ +import { addAtomic } from './addAtomic'; + +describe('addAtomic', () => { + it('sums atomic amounts as integer strings', () => { + expect(addAtomic('250000000', '9060000')).toBe('259060000'); + }); + + it('returns an integer string without scientific notation', () => { + expect(addAtomic('1000000000000', '1000000000000')).toBe('2000000000000'); + expect(addAtomic('1000000000000', '1000000000000')).not.toMatch(/e/i); + }); +}); diff --git a/backend/src/utils/atomic/addAtomic.ts b/backend/src/utils/atomic/addAtomic.ts new file mode 100644 index 0000000..c5ddf98 --- /dev/null +++ b/backend/src/utils/atomic/addAtomic.ts @@ -0,0 +1,5 @@ +import Decimal from 'decimal.js'; + +export const addAtomic = (leftAtomic: string, rightAtomic: string): string => { + return new Decimal(leftAtomic).plus(rightAtomic).toDecimalPlaces(0, Decimal.ROUND_HALF_UP).toString(); +}; diff --git a/backend/src/utils/atomic/isAtomicGte.spec.ts b/backend/src/utils/atomic/isAtomicGte.spec.ts new file mode 100644 index 0000000..66dc65c --- /dev/null +++ b/backend/src/utils/atomic/isAtomicGte.spec.ts @@ -0,0 +1,19 @@ +import { isAtomicGte } from './isAtomicGte'; + +describe('isAtomicGte', () => { + it('returns true when left atomic amount is greater than right', () => { + expect(isAtomicGte('200000000000', '100000000000')).toBe(true); + }); + + it('returns true when atomic amounts are equal', () => { + expect(isAtomicGte('100000000000', '100000000000')).toBe(true); + }); + + it('returns false when left atomic amount is less than right', () => { + expect(isAtomicGte('99999999999', '100000000000')).toBe(false); + }); + + it('compares large atomic amounts without precision loss', () => { + expect(isAtomicGte('1000000000000000000', '999999999999999999')).toBe(true); + }); +}); diff --git a/backend/src/utils/atomic/isAtomicGte.ts b/backend/src/utils/atomic/isAtomicGte.ts new file mode 100644 index 0000000..94b42d3 --- /dev/null +++ b/backend/src/utils/atomic/isAtomicGte.ts @@ -0,0 +1,5 @@ +import Decimal from 'decimal.js'; + +export const isAtomicGte = (leftAtomic: string, rightAtomic: string): boolean => { + return new Decimal(leftAtomic).gte(rightAtomic); +}; diff --git a/backend/src/utils/atomic/subtractAtomic.spec.ts b/backend/src/utils/atomic/subtractAtomic.spec.ts new file mode 100644 index 0000000..5709c94 --- /dev/null +++ b/backend/src/utils/atomic/subtractAtomic.spec.ts @@ -0,0 +1,12 @@ +import { subtractAtomic } from './subtractAtomic'; + +describe('subtractAtomic', () => { + it('subtracts atomic amounts as integer strings', () => { + expect(subtractAtomic('259070000', '259060000')).toBe('10000'); + }); + + it('returns an integer string without scientific notation', () => { + expect(subtractAtomic('1000000000000', '1')).toBe('999999999999'); + expect(subtractAtomic('1000000000000', '1')).not.toMatch(/e/i); + }); +}); diff --git a/backend/src/utils/atomic/subtractAtomic.ts b/backend/src/utils/atomic/subtractAtomic.ts new file mode 100644 index 0000000..e0ded98 --- /dev/null +++ b/backend/src/utils/atomic/subtractAtomic.ts @@ -0,0 +1,5 @@ +import Decimal from 'decimal.js'; + +export const subtractAtomic = (minuendAtomic: string, subtrahendAtomic: string): string => { + return new Decimal(minuendAtomic).minus(subtrahendAtomic).toDecimalPlaces(0, Decimal.ROUND_HALF_UP).toString(); +}; diff --git a/backend/src/utils/buildAllowedMimeRegex.spec.ts b/backend/src/utils/buildAllowedMimeRegex.spec.ts new file mode 100644 index 0000000..6b6bd25 --- /dev/null +++ b/backend/src/utils/buildAllowedMimeRegex.spec.ts @@ -0,0 +1,11 @@ +import { buildAllowedMimeRegex } from './buildAllowedMimeRegex'; + +describe('buildAllowedMimeRegex', () => { + it('matches only the allowed mime types', () => { + const regex = buildAllowedMimeRegex(['image/png', 'image/jpeg']); + + expect('image/png').toMatch(regex); + expect('image/jpeg').toMatch(regex); + expect('application/pdf').not.toMatch(regex); + }); +}); diff --git a/backend/src/utils/buildAllowedMimeRegex.ts b/backend/src/utils/buildAllowedMimeRegex.ts new file mode 100644 index 0000000..5e2b13e --- /dev/null +++ b/backend/src/utils/buildAllowedMimeRegex.ts @@ -0,0 +1,2 @@ +export const buildAllowedMimeRegex = (allowedMimes: readonly string[]): RegExp => + new RegExp(`^(${allowedMimes.join('|')})$`); diff --git a/backend/src/utils/cart/getQtyByVariantIdFromCart.spec.ts b/backend/src/utils/cart/getQtyByVariantIdFromCart.spec.ts new file mode 100644 index 0000000..4f4dd12 --- /dev/null +++ b/backend/src/utils/cart/getQtyByVariantIdFromCart.spec.ts @@ -0,0 +1,22 @@ +import { getQtyByVariantIdFromCart } from './getQtyByVariantIdFromCart'; + +describe('getQtyByVariantIdFromCart', () => { + it('returns an empty map for an empty cart', () => { + expect(getQtyByVariantIdFromCart([])).toEqual(new Map()); + }); + + it('sums quantities for duplicate variant rows', () => { + expect( + getQtyByVariantIdFromCart([ + { variantId: 'variant-1', qty: 2 }, + { variantId: 'variant-1', qty: 3 }, + { variantId: 'variant-2', qty: 1 } + ]) + ).toEqual( + new Map([ + ['variant-1', 5], + ['variant-2', 1] + ]) + ); + }); +}); diff --git a/backend/src/utils/cart/getQtyByVariantIdFromCart.ts b/backend/src/utils/cart/getQtyByVariantIdFromCart.ts new file mode 100644 index 0000000..f12a8c5 --- /dev/null +++ b/backend/src/utils/cart/getQtyByVariantIdFromCart.ts @@ -0,0 +1,11 @@ +import type { CookieCart } from '../../modules/storefrontCore/types/cart/CookieCart'; + +export const getQtyByVariantIdFromCart = (cart: CookieCart): Map => { + const map = new Map(); + + for (const line of cart) { + map.set(line.variantId, (map.get(line.variantId) ?? 0) + line.qty); + } + + return map; +}; diff --git a/backend/src/utils/cart/getTotalCartQtyFromCart.spec.ts b/backend/src/utils/cart/getTotalCartQtyFromCart.spec.ts new file mode 100644 index 0000000..acd1ccc --- /dev/null +++ b/backend/src/utils/cart/getTotalCartQtyFromCart.spec.ts @@ -0,0 +1,16 @@ +import { getTotalCartQtyFromCart } from './getTotalCartQtyFromCart'; + +describe('getTotalCartQtyFromCart', () => { + it('returns zero for an empty cart', () => { + expect(getTotalCartQtyFromCart([])).toBe(0); + }); + + it('sums line quantities', () => { + expect( + getTotalCartQtyFromCart([ + { variantId: 'variant-1', qty: 2 }, + { variantId: 'variant-2', qty: 3 } + ]) + ).toBe(5); + }); +}); diff --git a/backend/src/utils/cart/getTotalCartQtyFromCart.ts b/backend/src/utils/cart/getTotalCartQtyFromCart.ts new file mode 100644 index 0000000..fe5c38b --- /dev/null +++ b/backend/src/utils/cart/getTotalCartQtyFromCart.ts @@ -0,0 +1,3 @@ +import type { CookieCart } from '../../modules/storefrontCore/types/cart/CookieCart'; + +export const getTotalCartQtyFromCart = (cart: CookieCart): number => cart.reduce((sum, line) => sum + line.qty, 0); diff --git a/backend/src/utils/checkout/deriveCheckoutSessionState.spec.ts b/backend/src/utils/checkout/deriveCheckoutSessionState.spec.ts new file mode 100644 index 0000000..c6af2fb --- /dev/null +++ b/backend/src/utils/checkout/deriveCheckoutSessionState.spec.ts @@ -0,0 +1,109 @@ +import { PaymentMethod } from '../../modules/payment/types/PaymentMethod'; +import { deriveCheckoutSessionState } from './deriveCheckoutSessionState'; + +describe('deriveCheckoutSessionState', () => { + const openInvoice = { + paymentMethod: PaymentMethod.Xmr, + expectedTotalAtomic: '1000', + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + moneroDetails: { requiredConfirmations: 1 }, + payments: [] as { amountAtomic: string; confirmations: number }[] + }; + + it('detects cancelled sessions', () => { + const cancelledState = deriveCheckoutSessionState({ cancelledAt: new Date() }); + const openState = deriveCheckoutSessionState({ cancelledAt: null }); + + expect(cancelledState.isCancelled).toBe(true); + expect(openState.isCancelled).toBe(false); + }); + + it('keeps an unpaid non-expired session open for payment', () => { + const state = deriveCheckoutSessionState({ + cancelledAt: null, + invoice: openInvoice + }); + + expect(state.isOpenForPayment).toBe(true); + expect(state.isPastDue).toBe(false); + }); + + it('treats underpaid non-expired sessions as still open for payment', () => { + const state = deriveCheckoutSessionState({ + cancelledAt: null, + invoice: { + ...openInvoice, + payments: [{ amountAtomic: '100', confirmations: 0 }] + } + }); + + expect(state.invoice?.isUnderpaid).toBe(true); + expect(state.isOpenForPayment).toBe(true); + expect(state.isPastDue).toBe(false); + }); + + it('closes payment once the invoice is paid in full', () => { + const state = deriveCheckoutSessionState({ + cancelledAt: null, + invoice: { + ...openInvoice, + payments: [{ amountAtomic: '1000', confirmations: 0 }] + } + }); + + expect(state.invoice?.isPaidSufficient).toBe(true); + expect(state.isOpenForPayment).toBe(false); + expect(state.isPastDue).toBe(false); + }); + + it('treats expired unpaid sessions as past due', () => { + const state = deriveCheckoutSessionState({ + cancelledAt: null, + invoice: { + ...openInvoice, + expiresAt: new Date('2020-01-01T00:00:00.000Z') + } + }); + + expect(state.invoice?.isExpired).toBe(true); + expect(state.isOpenForPayment).toBe(true); + expect(state.isPastDue).toBe(true); + }); + + it('does not treat a fully paid expired invoice as past due', () => { + const state = deriveCheckoutSessionState({ + cancelledAt: null, + invoice: { + ...openInvoice, + payments: [{ amountAtomic: '1000', confirmations: 0 }], + expiresAt: new Date('2020-01-01T00:00:00.000Z') + } + }); + + expect(state.invoice?.isExpired).toBe(true); + expect(state.invoice?.isPaidSufficient).toBe(true); + expect(state.isOpenForPayment).toBe(false); + expect(state.isPastDue).toBe(false); + }); + + it('blocks payment on cancelled sessions even when the invoice is still valid', () => { + const state = deriveCheckoutSessionState({ + cancelledAt: new Date(), + invoice: openInvoice + }); + + expect(state.isCancelled).toBe(true); + expect(state.isOpenForPayment).toBe(false); + expect(state.isPastDue).toBe(false); + }); + + it('does not open payment when the session has no invoice', () => { + const state = deriveCheckoutSessionState({ + cancelledAt: null + }); + + expect(state.invoice).toBeNull(); + expect(state.isOpenForPayment).toBe(false); + expect(state.isPastDue).toBe(false); + }); +}); diff --git a/backend/src/utils/checkout/deriveCheckoutSessionState.ts b/backend/src/utils/checkout/deriveCheckoutSessionState.ts new file mode 100644 index 0000000..adb2b26 --- /dev/null +++ b/backend/src/utils/checkout/deriveCheckoutSessionState.ts @@ -0,0 +1,18 @@ +import { deriveInvoiceState } from '../invoice/deriveInvoiceState'; +import { isSet } from '../isSet'; +import type { CheckoutSessionStateInput } from './types/CheckoutSessionStateInput'; +import type { CheckoutSessionState } from './types/CheckoutSessionState'; + +export const deriveCheckoutSessionState = (session: CheckoutSessionStateInput): CheckoutSessionState => { + const isCancelled = isSet(session.cancelledAt); + const invoice = session.invoice ? deriveInvoiceState(session.invoice) : null; + const isOpenForPayment = !isCancelled && invoice !== null && !invoice.isPaidSufficient; + const isPastDue = isOpenForPayment && invoice.isExpired; + + return { + isCancelled, + invoice, + isOpenForPayment, + isPastDue + }; +}; diff --git a/backend/src/utils/checkout/deriveCheckoutTotals.spec.ts b/backend/src/utils/checkout/deriveCheckoutTotals.spec.ts new file mode 100644 index 0000000..c10d0cf --- /dev/null +++ b/backend/src/utils/checkout/deriveCheckoutTotals.spec.ts @@ -0,0 +1,67 @@ +import { deriveCheckoutTotals } from './deriveCheckoutTotals'; +import type { CheckoutTotalsInput } from './types/CheckoutTotalsInput'; + +const baseCheckout = (overrides: Partial = {}): CheckoutTotalsInput => ({ + lines: [{ lineSubtotalFiat: 10 }], + discounts: [], + invoice: { amountFiat: 10 }, + ...overrides +}); + +describe('deriveCheckoutTotals', () => { + it('sums subtotal from multiple lines', () => { + const totals = deriveCheckoutTotals( + baseCheckout({ + lines: [{ lineSubtotalFiat: 10 }, { lineSubtotalFiat: 25.5 }] + }) + ); + + expect(totals.subtotalFiat).toBe(35.5); + }); + + it('sums discount total', () => { + const totals = deriveCheckoutTotals( + baseCheckout({ + discounts: [{ amountFiat: 2 }, { amountFiat: 3.5 }] + }) + ); + + expect(totals.discountTotalFiat).toBe(5.5); + }); + + it('uses invoice amount for totalFiat', () => { + const totals = deriveCheckoutTotals( + baseCheckout({ + lines: [{ lineSubtotalFiat: 100 }], + discounts: [{ amountFiat: 10 }], + invoice: { amountFiat: 90 } + }) + ); + + expect(totals.subtotalFiat).toBe(100); + expect(totals.discountTotalFiat).toBe(10); + expect(totals.totalFiat).toBe(90); + }); + + it('defaults missing invoice to totalFiat 0', () => { + const totals = deriveCheckoutTotals( + baseCheckout({ + invoice: null + }) + ); + + expect(totals.totalFiat).toBe(0); + }); + + it('defaults missing lines and discounts to zero', () => { + const totals = deriveCheckoutTotals( + baseCheckout({ + lines: undefined, + discounts: undefined + }) + ); + + expect(totals.subtotalFiat).toBe(0); + expect(totals.discountTotalFiat).toBe(0); + }); +}); diff --git a/backend/src/utils/checkout/deriveCheckoutTotals.ts b/backend/src/utils/checkout/deriveCheckoutTotals.ts new file mode 100644 index 0000000..1199353 --- /dev/null +++ b/backend/src/utils/checkout/deriveCheckoutTotals.ts @@ -0,0 +1,15 @@ +import { sumByKey } from '../sumByKey'; +import type { CheckoutTotals } from './types/CheckoutTotals'; +import type { CheckoutTotalsInput } from './types/CheckoutTotalsInput'; + +export const deriveCheckoutTotals = (checkout: CheckoutTotalsInput): CheckoutTotals => { + const subtotalFiat = sumByKey(checkout.lines ?? [], 'lineSubtotalFiat'); + const discountTotalFiat = sumByKey(checkout.discounts ?? [], 'amountFiat'); + const totalFiat = checkout.invoice?.amountFiat ?? 0; + + return { + subtotalFiat, + discountTotalFiat, + totalFiat + }; +}; diff --git a/backend/src/utils/checkout/types/CheckoutSessionState.ts b/backend/src/utils/checkout/types/CheckoutSessionState.ts new file mode 100644 index 0000000..2bc0475 --- /dev/null +++ b/backend/src/utils/checkout/types/CheckoutSessionState.ts @@ -0,0 +1,8 @@ +import type { InvoiceState } from '../../invoice/types/InvoiceState'; + +export type CheckoutSessionState = { + isCancelled: boolean; + invoice: InvoiceState | null; + isOpenForPayment: boolean; + isPastDue: boolean; +}; diff --git a/backend/src/utils/checkout/types/CheckoutSessionStateInput.ts b/backend/src/utils/checkout/types/CheckoutSessionStateInput.ts new file mode 100644 index 0000000..b179ac3 --- /dev/null +++ b/backend/src/utils/checkout/types/CheckoutSessionStateInput.ts @@ -0,0 +1,6 @@ +import type { InvoiceStateInput } from '../../invoice/types/InvoiceStateInput'; + +export type CheckoutSessionStateInput = { + cancelledAt: Date | null; + invoice?: InvoiceStateInput | null; +}; diff --git a/backend/src/utils/checkout/types/CheckoutTotals.ts b/backend/src/utils/checkout/types/CheckoutTotals.ts new file mode 100644 index 0000000..dfbc7c0 --- /dev/null +++ b/backend/src/utils/checkout/types/CheckoutTotals.ts @@ -0,0 +1,5 @@ +export type CheckoutTotals = { + subtotalFiat: number; + discountTotalFiat: number; + totalFiat: number; +}; diff --git a/backend/src/utils/checkout/types/CheckoutTotalsInput.ts b/backend/src/utils/checkout/types/CheckoutTotalsInput.ts new file mode 100644 index 0000000..238dd25 --- /dev/null +++ b/backend/src/utils/checkout/types/CheckoutTotalsInput.ts @@ -0,0 +1,5 @@ +export type CheckoutTotalsInput = { + lines?: Array<{ lineSubtotalFiat: number }>; + discounts?: Array<{ amountFiat: number }>; + invoice?: { amountFiat: number } | null; +}; diff --git a/backend/src/utils/createDiskStorageUploadOptions.ts b/backend/src/utils/createDiskStorageUploadOptions.ts new file mode 100644 index 0000000..5169be8 --- /dev/null +++ b/backend/src/utils/createDiskStorageUploadOptions.ts @@ -0,0 +1,20 @@ +import { randomUUID } from 'node:crypto'; +import { mkdirSync } from 'node:fs'; +import { diskStorage } from 'multer'; + +import { getFileExtensionFromMimeType } from './getFileExtensionFromMimeType'; + +export const createDiskStorageUploadOptions = (uploadDir: string) => ({ + storage: diskStorage({ + destination: (_req, _file, cb) => { + mkdirSync(uploadDir, { recursive: true }); + + cb(null, uploadDir); + }, + filename: (_req, file, cb) => { + const ext = getFileExtensionFromMimeType(file.mimetype); + + cb(null, `${randomUUID()}.${ext}`); + } + }) +}); diff --git a/backend/src/utils/createUploadFilePipe.ts b/backend/src/utils/createUploadFilePipe.ts new file mode 100644 index 0000000..14d0ff9 --- /dev/null +++ b/backend/src/utils/createUploadFilePipe.ts @@ -0,0 +1,26 @@ +import { MaxFileSizeValidator, ParseFilePipe } from '@nestjs/common'; + +import type { UploadFileSource } from '../types/UploadFileSource'; +import { buildAllowedMimeRegex } from './buildAllowedMimeRegex'; +import { BufferFileTypeValidator } from './BufferFileTypeValidator'; +import { DiskFileTypeValidator } from './DiskFileTypeValidator'; + +export const createUploadFilePipe = ( + allowedMimes: readonly string[], + maxFileBytes: number, + source: UploadFileSource = 'disk' +): ParseFilePipe => { + const fileType = buildAllowedMimeRegex(allowedMimes); + + const fileTypeValidator = + source === 'buffer' ? new BufferFileTypeValidator({ fileType }) : new DiskFileTypeValidator({ fileType }); + + return new ParseFilePipe({ + validators: [ + new MaxFileSizeValidator({ + maxSize: maxFileBytes + }), + fileTypeValidator + ] + }); +}; diff --git a/backend/src/utils/formatRelativeTimeAgo.spec.ts b/backend/src/utils/formatRelativeTimeAgo.spec.ts new file mode 100644 index 0000000..78cc0c6 --- /dev/null +++ b/backend/src/utils/formatRelativeTimeAgo.spec.ts @@ -0,0 +1,11 @@ +import dayjs from '../plugins/dayjs'; +import { formatRelativeTimeAgo } from './formatRelativeTimeAgo'; + +describe('formatRelativeTimeAgo', () => { + it('formats a relative time from the provided now date', () => { + const now = new Date('2026-01-02T12:00:00.000Z'); + const earlier = new Date('2026-01-02T11:00:00.000Z'); + + expect(formatRelativeTimeAgo(earlier, now)).toBe(dayjs(earlier).from(dayjs(now))); + }); +}); diff --git a/backend/src/utils/formatRelativeTimeAgo.ts b/backend/src/utils/formatRelativeTimeAgo.ts new file mode 100644 index 0000000..7627682 --- /dev/null +++ b/backend/src/utils/formatRelativeTimeAgo.ts @@ -0,0 +1,5 @@ +import dayjs from '../plugins/dayjs'; + +export const formatRelativeTimeAgo = (date: Date, now = new Date()): string => { + return dayjs(date).from(dayjs(now)); +}; diff --git a/backend/src/utils/generateQrCodeDataUrl.ts b/backend/src/utils/generateQrCodeDataUrl.ts new file mode 100644 index 0000000..336e192 --- /dev/null +++ b/backend/src/utils/generateQrCodeDataUrl.ts @@ -0,0 +1,5 @@ +import QRCode from 'qrcode'; + +export const generateQrCodeDataUrl = (data: string, size = 220): Promise => { + return QRCode.toDataURL(data, { width: size, margin: 1 }); +}; diff --git a/backend/src/utils/getErrorMessage.spec.ts b/backend/src/utils/getErrorMessage.spec.ts new file mode 100644 index 0000000..ee6c238 --- /dev/null +++ b/backend/src/utils/getErrorMessage.spec.ts @@ -0,0 +1,12 @@ +import { getErrorMessage } from './getErrorMessage'; + +describe('getErrorMessage', () => { + it('returns the message from Error instances', () => { + expect(getErrorMessage(new Error('boom'))).toBe('boom'); + }); + + it('stringifies non-error values', () => { + expect(getErrorMessage('plain')).toBe('plain'); + expect(getErrorMessage(404)).toBe('404'); + }); +}); diff --git a/backend/src/utils/getErrorMessage.ts b/backend/src/utils/getErrorMessage.ts new file mode 100644 index 0000000..84ba131 --- /dev/null +++ b/backend/src/utils/getErrorMessage.ts @@ -0,0 +1 @@ +export const getErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error)); diff --git a/backend/src/utils/getFileExtensionFromMimeType.spec.ts b/backend/src/utils/getFileExtensionFromMimeType.spec.ts new file mode 100644 index 0000000..a151905 --- /dev/null +++ b/backend/src/utils/getFileExtensionFromMimeType.spec.ts @@ -0,0 +1,26 @@ +import { getFileExtensionFromMimeType } from './getFileExtensionFromMimeType'; + +describe('getFileExtensionFromMimeType', () => { + it('maps JPEG MIME subtypes to jpg', () => { + expect(getFileExtensionFromMimeType('image/jpeg')).toBe('jpg'); + expect(getFileExtensionFromMimeType('image/jpg')).toBe('jpg'); + }); + + it('maps ICO MIME subtypes to ico', () => { + expect(getFileExtensionFromMimeType('image/x-icon')).toBe('ico'); + expect(getFileExtensionFromMimeType('image/vnd.microsoft.icon')).toBe('ico'); + }); + + it('returns the MIME subtype for common extensions', () => { + expect(getFileExtensionFromMimeType('image/png')).toBe('png'); + expect(getFileExtensionFromMimeType('application/pdf')).toBe('pdf'); + }); + + it('strips structured suffix before mapping', () => { + expect(getFileExtensionFromMimeType('image/svg+xml')).toBe('svg'); + }); + + it('falls back to bin for invalid MIME types', () => { + expect(getFileExtensionFromMimeType('invalid')).toBe('bin'); + }); +}); diff --git a/backend/src/utils/getFileExtensionFromMimeType.ts b/backend/src/utils/getFileExtensionFromMimeType.ts new file mode 100644 index 0000000..f70445b --- /dev/null +++ b/backend/src/utils/getFileExtensionFromMimeType.ts @@ -0,0 +1,13 @@ +export const getFileExtensionFromMimeType = (mimetype: string): string => { + const sub = (mimetype.split('/')[1]?.split('+')[0] ?? 'bin').toLowerCase(); + + if (sub === 'jpeg' || sub === 'jpg') { + return 'jpg'; + } + + if (sub === 'x-icon' || sub === 'vnd.microsoft.icon') { + return 'ico'; + } + + return sub; +}; diff --git a/backend/src/utils/invoice/deriveInvoiceConfirmationsMet.spec.ts b/backend/src/utils/invoice/deriveInvoiceConfirmationsMet.spec.ts new file mode 100644 index 0000000..b4d6e54 --- /dev/null +++ b/backend/src/utils/invoice/deriveInvoiceConfirmationsMet.spec.ts @@ -0,0 +1,37 @@ +import { PaymentMethod } from '../../modules/payment/types/PaymentMethod'; +import { deriveInvoiceConfirmationsMet } from './deriveInvoiceConfirmationsMet'; + +describe('deriveInvoiceConfirmationsMet', () => { + const invoice = { + paymentMethod: PaymentMethod.Xmr, + moneroDetails: { requiredConfirmations: 3 } + }; + + it('returns false when there are no payments', () => { + expect(deriveInvoiceConfirmationsMet(invoice)).toBe(false); + }); + + it('returns false when any payment is below the required confirmations', () => { + expect( + deriveInvoiceConfirmationsMet({ + ...invoice, + payments: [ + { confirmations: 3 }, + { confirmations: 2 } + ] + }) + ).toBe(false); + }); + + it('returns true when every payment meets the required confirmations', () => { + expect( + deriveInvoiceConfirmationsMet({ + ...invoice, + payments: [ + { confirmations: 3 }, + { confirmations: 4 } + ] + }) + ).toBe(true); + }); +}); diff --git a/backend/src/utils/invoice/deriveInvoiceConfirmationsMet.ts b/backend/src/utils/invoice/deriveInvoiceConfirmationsMet.ts new file mode 100644 index 0000000..2e18eb3 --- /dev/null +++ b/backend/src/utils/invoice/deriveInvoiceConfirmationsMet.ts @@ -0,0 +1,13 @@ +import type { InvoiceConfirmationsInput } from './types/InvoiceConfirmationsInput'; +import { resolveInvoiceRequiredConfirmations } from './resolveInvoiceRequiredConfirmations'; + +export const deriveInvoiceConfirmationsMet = (invoice: InvoiceConfirmationsInput): boolean => { + const requiredConfirmations = resolveInvoiceRequiredConfirmations(invoice); + const payments = invoice.payments; + + if (!payments || payments.length === 0) { + return false; + } + + return payments.every(payment => payment.confirmations >= requiredConfirmations); +}; diff --git a/backend/src/utils/invoice/deriveInvoiceState.spec.ts b/backend/src/utils/invoice/deriveInvoiceState.spec.ts new file mode 100644 index 0000000..ace25ec --- /dev/null +++ b/backend/src/utils/invoice/deriveInvoiceState.spec.ts @@ -0,0 +1,115 @@ +import { PaymentMethod } from '../../modules/payment/types/PaymentMethod'; +import { deriveInvoiceState } from './deriveInvoiceState'; + +describe('deriveInvoiceState', () => { + const invoice = { + paymentMethod: PaymentMethod.Xmr, + expectedTotalAtomic: '1000', + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + moneroDetails: { requiredConfirmations: 1 }, + payments: [] as { amountAtomic: string; confirmations: number }[] + }; + + it('derives awaiting payment when nothing was received', () => { + const state = deriveInvoiceState(invoice); + + expect(state.isAwaitingPayment).toBe(true); + expect(state.isUnderpaid).toBe(false); + expect(state.isPaidSufficient).toBe(false); + expect(state.isPaidAwaitingConfirmations).toBe(false); + expect(state.isPaidAndConfirmed).toBe(false); + expect(state.hasPendingConfirmations).toBe(false); + }); + + it('derives underpaid from invoice payments', () => { + const underpaidState = deriveInvoiceState({ + ...invoice, + payments: [{ amountAtomic: '100', confirmations: 0 }] + }); + const unpaidState = deriveInvoiceState(invoice); + + expect(underpaidState.isUnderpaid).toBe(true); + expect(underpaidState.isAwaitingPayment).toBe(false); + expect(unpaidState.isUnderpaid).toBe(false); + }); + + it('derives paid sufficient from invoice payments', () => { + const state = deriveInvoiceState({ + ...invoice, + payments: [{ amountAtomic: '1000', confirmations: 0 }] + }); + + expect(state.isPaidSufficient).toBe(true); + expect(state.isAwaitingPayment).toBe(false); + }); + + it('derives awaiting confirmations when paid sufficient but confirmations are pending', () => { + const state = deriveInvoiceState({ + ...invoice, + moneroDetails: { requiredConfirmations: 3 }, + payments: [{ amountAtomic: '1000', confirmations: 1 }] + }); + + expect(state.isPaidSufficient).toBe(true); + expect(state.isPaidAwaitingConfirmations).toBe(true); + expect(state.isPaidAndConfirmed).toBe(false); + expect(state.hasPendingConfirmations).toBe(true); + }); + + it('derives paid and confirmed when amount and confirmations are sufficient', () => { + const state = deriveInvoiceState({ + ...invoice, + moneroDetails: { requiredConfirmations: 1 }, + payments: [{ amountAtomic: '1000', confirmations: 1 }] + }); + + expect(state.isPaidAndConfirmed).toBe(true); + expect(state.isPaidAwaitingConfirmations).toBe(false); + expect(state.hasPendingConfirmations).toBe(false); + }); + + it('does not treat underpaid invoices as awaiting confirmations even when partial txs are confirmed', () => { + const state = deriveInvoiceState({ + ...invoice, + moneroDetails: { requiredConfirmations: 1 }, + payments: [{ amountAtomic: '100', confirmations: 10 }] + }); + + expect(state.isUnderpaid).toBe(true); + expect(state.isPaidAwaitingConfirmations).toBe(false); + expect(state.isPaidAndConfirmed).toBe(false); + expect(state.hasPendingConfirmations).toBe(false); + }); + + it('derives hasPendingConfirmations for underpaid invoices with unconfirmed partial txs', () => { + const state = deriveInvoiceState({ + ...invoice, + moneroDetails: { requiredConfirmations: 3 }, + payments: [{ amountAtomic: '100', confirmations: 1 }] + }); + + expect(state.isUnderpaid).toBe(true); + expect(state.hasPendingConfirmations).toBe(true); + }); + + it('derives expired from invoice expiry', () => { + const expiredState = deriveInvoiceState({ + ...invoice, + expiresAt: new Date('2020-01-01T00:00:00.000Z') + }); + const openState = deriveInvoiceState(invoice); + + expect(expiredState.isExpired).toBe(true); + expect(openState.isExpired).toBe(false); + }); + + it('throws when monero details are missing', () => { + expect(() => + deriveInvoiceState({ + ...invoice, + moneroDetails: undefined, + payments: [{ amountAtomic: '1000', confirmations: 10 }] + }) + ).toThrow('Invoice is missing Monero required confirmations'); + }); +}); diff --git a/backend/src/utils/invoice/deriveInvoiceState.ts b/backend/src/utils/invoice/deriveInvoiceState.ts new file mode 100644 index 0000000..755b17f --- /dev/null +++ b/backend/src/utils/invoice/deriveInvoiceState.ts @@ -0,0 +1,40 @@ +import dayjs from '../../plugins/dayjs'; +import { isAtomicGte } from '../atomic/isAtomicGte'; +import { deriveInvoiceConfirmationsMet } from './deriveInvoiceConfirmationsMet'; +import { resolveInvoiceRequiredConfirmations } from './resolveInvoiceRequiredConfirmations'; +import { sumInvoicePaymentAmountsAtomic } from './sumInvoicePaymentAmountsAtomic'; +import type { InvoiceState } from './types/InvoiceState'; +import type { InvoiceStateInput } from './types/InvoiceStateInput'; + +export const deriveInvoiceState = (invoice: InvoiceStateInput): InvoiceState => { + const receivedAmountAtomic = sumInvoicePaymentAmountsAtomic(invoice.payments); + const isPaidSufficient = isAtomicGte(receivedAmountAtomic, invoice.expectedTotalAtomic); + const isUnderpaid = receivedAmountAtomic !== '0' && !isPaidSufficient; + const isAwaitingPayment = receivedAmountAtomic === '0'; + const isExpired = dayjs(invoice.expiresAt).isBefore(dayjs()); + const confirmationsMet = deriveInvoiceConfirmationsMet(invoice); + const isPaidAwaitingConfirmations = isPaidSufficient && !confirmationsMet; + const isPaidAndConfirmed = isPaidSufficient && confirmationsMet; + const hasPendingConfirmations = deriveHasPendingConfirmations(invoice); + + return { + isAwaitingPayment, + isUnderpaid, + isPaidSufficient, + isPaidAwaitingConfirmations, + isPaidAndConfirmed, + isExpired, + hasPendingConfirmations + }; +}; + +const deriveHasPendingConfirmations = (invoice: InvoiceStateInput): boolean => { + const requiredConfirmations = resolveInvoiceRequiredConfirmations(invoice); + const payments = invoice.payments; + + if (!payments || payments.length === 0) { + return false; + } + + return payments.some(payment => payment.confirmations < requiredConfirmations); +}; diff --git a/backend/src/utils/invoice/formatInvoicePaymentConfirmationStatus.spec.ts b/backend/src/utils/invoice/formatInvoicePaymentConfirmationStatus.spec.ts new file mode 100644 index 0000000..ca70b66 --- /dev/null +++ b/backend/src/utils/invoice/formatInvoicePaymentConfirmationStatus.spec.ts @@ -0,0 +1,88 @@ +import * as formatRelativeTimeAgoModule from '../formatRelativeTimeAgo'; +import { formatInvoicePaymentConfirmationStatus } from './formatInvoicePaymentConfirmationStatus'; + +describe('formatInvoicePaymentConfirmationStatus', () => { + let formatRelativeTimeAgoSpy: jest.SpiedFunction; + + beforeEach(() => { + formatRelativeTimeAgoSpy = jest + .spyOn(formatRelativeTimeAgoModule, 'formatRelativeTimeAgo') + .mockReturnValue('2 hours ago'); + }); + + afterEach(() => { + formatRelativeTimeAgoSpy.mockRestore(); + }); + + describe('compact', () => { + it('returns Confirmed when confirmations meet the requirement', () => { + expect( + formatInvoicePaymentConfirmationStatus({ + confirmations: 3, + requiredConfirmations: 3, + format: 'compact' + }) + ).toBe('Confirmed'); + }); + + it('returns compact progress with a slash separator', () => { + expect( + formatInvoicePaymentConfirmationStatus({ + confirmations: 2, + requiredConfirmations: 10, + format: 'compact' + }) + ).toBe('2/10'); + }); + + it('treats zero-confirmation tiers as confirmed', () => { + expect( + formatInvoicePaymentConfirmationStatus({ + confirmations: 0, + requiredConfirmations: 0, + format: 'compact' + }) + ).toBe('Confirmed'); + }); + }); + + describe('extended', () => { + const createdAt = new Date('2026-01-01T12:00:00.000Z'); + + it('returns Confirmed when confirmations meet the requirement', () => { + expect( + formatInvoicePaymentConfirmationStatus({ + confirmations: 1, + requiredConfirmations: 1, + createdAt, + format: 'extended' + }) + ).toBe('Confirmed'); + + expect(formatRelativeTimeAgoSpy).not.toHaveBeenCalled(); + }); + + it('returns extended progress with relative detection time', () => { + expect( + formatInvoicePaymentConfirmationStatus({ + confirmations: 2, + requiredConfirmations: 10, + createdAt, + format: 'extended' + }) + ).toBe('2 / 10 confirmations · detected 2 hours ago'); + + expect(formatRelativeTimeAgoSpy).toHaveBeenCalledWith(createdAt); + }); + + it('requires createdAt for extended format', () => { + expect(() => + formatInvoicePaymentConfirmationStatus({ + confirmations: 1, + requiredConfirmations: 3, + format: 'extended' + }) + ).toThrow('createdAt is required for extended confirmation status format'); + }); + }); +}); diff --git a/backend/src/utils/invoice/formatInvoicePaymentConfirmationStatus.ts b/backend/src/utils/invoice/formatInvoicePaymentConfirmationStatus.ts new file mode 100644 index 0000000..197147d --- /dev/null +++ b/backend/src/utils/invoice/formatInvoicePaymentConfirmationStatus.ts @@ -0,0 +1,33 @@ +import { formatRelativeTimeAgo } from '../formatRelativeTimeAgo'; +import type { InvoicePaymentConfirmationStatusFormat } from './types/InvoicePaymentConfirmationStatusFormat'; + +const formatRequiredConfirmationsLabel = (requiredConfirmations: number): string => + requiredConfirmations === 0 ? '0 (tx-detected)' : String(requiredConfirmations); + +export const formatInvoicePaymentConfirmationStatus = ({ + confirmations, + requiredConfirmations, + createdAt, + format +}: { + confirmations: number; + requiredConfirmations: number; + createdAt?: Date; + format: InvoicePaymentConfirmationStatusFormat; +}): string => { + if (confirmations >= requiredConfirmations) { + return 'Confirmed'; + } + + if (format === 'extended') { + if (!createdAt) { + throw new Error('createdAt is required for extended confirmation status format'); + } + + const detectedAgo = formatRelativeTimeAgo(createdAt); + + return `${confirmations} / ${requiredConfirmations} confirmations · detected ${detectedAgo}`; + } + + return `${confirmations}/${formatRequiredConfirmationsLabel(requiredConfirmations)}`; +}; diff --git a/backend/src/utils/invoice/resolveInvoiceRequiredConfirmations.spec.ts b/backend/src/utils/invoice/resolveInvoiceRequiredConfirmations.spec.ts new file mode 100644 index 0000000..ecd77fb --- /dev/null +++ b/backend/src/utils/invoice/resolveInvoiceRequiredConfirmations.spec.ts @@ -0,0 +1,30 @@ +import { PaymentMethod } from '../../modules/payment/types/PaymentMethod'; +import { resolveInvoiceRequiredConfirmations } from './resolveInvoiceRequiredConfirmations'; + +describe('resolveInvoiceRequiredConfirmations', () => { + it('returns required confirmations for XMR invoices', () => { + expect( + resolveInvoiceRequiredConfirmations({ + paymentMethod: PaymentMethod.Xmr, + moneroDetails: { requiredConfirmations: 3 } + }) + ).toBe(3); + }); + + it('throws when monero details are missing', () => { + expect(() => + resolveInvoiceRequiredConfirmations({ + paymentMethod: PaymentMethod.Xmr, + moneroDetails: null + }) + ).toThrow('Invoice is missing Monero required confirmations'); + }); + + it('throws for unsupported payment methods', () => { + expect(() => + resolveInvoiceRequiredConfirmations({ + paymentMethod: 'btc' as PaymentMethod + }) + ).toThrow('Unsupported payment method: btc'); + }); +}); diff --git a/backend/src/utils/invoice/resolveInvoiceRequiredConfirmations.ts b/backend/src/utils/invoice/resolveInvoiceRequiredConfirmations.ts new file mode 100644 index 0000000..0582717 --- /dev/null +++ b/backend/src/utils/invoice/resolveInvoiceRequiredConfirmations.ts @@ -0,0 +1,18 @@ +import { PaymentMethod } from '../../modules/payment/types/PaymentMethod'; +import type { InvoiceConfirmationsInput } from './types/InvoiceConfirmationsInput'; + +export const resolveInvoiceRequiredConfirmations = (invoice: InvoiceConfirmationsInput): number => { + switch (invoice.paymentMethod) { + case PaymentMethod.Xmr: { + const requiredConfirmations = invoice.moneroDetails?.requiredConfirmations; + + if (requiredConfirmations === undefined) { + throw new Error('Invoice is missing Monero required confirmations'); + } + + return requiredConfirmations; + } + default: + throw new Error(`Unsupported payment method: ${String(invoice.paymentMethod)}`); + } +}; diff --git a/backend/src/utils/invoice/resolveInvoiceStatusMessage.spec.ts b/backend/src/utils/invoice/resolveInvoiceStatusMessage.spec.ts new file mode 100644 index 0000000..af81bfa --- /dev/null +++ b/backend/src/utils/invoice/resolveInvoiceStatusMessage.spec.ts @@ -0,0 +1,38 @@ +import { resolveInvoiceStatusMessage } from './resolveInvoiceStatusMessage'; +import type { InvoiceState } from './types/InvoiceState'; + +const baseState: InvoiceState = { + isAwaitingPayment: false, + isUnderpaid: false, + isPaidSufficient: false, + isPaidAwaitingConfirmations: false, + isPaidAndConfirmed: false, + isExpired: false, + hasPendingConfirmations: false +}; + +describe('resolveInvoiceStatusMessage', () => { + it('returns Payment confirmed when paid and confirmed', () => { + expect(resolveInvoiceStatusMessage({ ...baseState, isPaidAndConfirmed: true })).toBe('Payment confirmed'); + }); + + it('returns Awaiting confirmations when confirmations are pending', () => { + expect(resolveInvoiceStatusMessage({ ...baseState, isPaidAwaitingConfirmations: true })).toBe( + 'Awaiting confirmations' + ); + }); + + it('returns Payment expired for expired unpaid invoices', () => { + expect(resolveInvoiceStatusMessage({ ...baseState, isExpired: true, isUnderpaid: true })).toBe( + 'Payment expired' + ); + }); + + it('returns Partial payment received for underpaid invoices', () => { + expect(resolveInvoiceStatusMessage({ ...baseState, isUnderpaid: true })).toBe('Partial payment received'); + }); + + it('returns Awaiting payment when nothing was received', () => { + expect(resolveInvoiceStatusMessage({ ...baseState, isAwaitingPayment: true })).toBe('Awaiting payment'); + }); +}); diff --git a/backend/src/utils/invoice/resolveInvoiceStatusMessage.ts b/backend/src/utils/invoice/resolveInvoiceStatusMessage.ts new file mode 100644 index 0000000..45d37aa --- /dev/null +++ b/backend/src/utils/invoice/resolveInvoiceStatusMessage.ts @@ -0,0 +1,28 @@ +import type { InvoiceState } from './types/InvoiceState'; +import type { InvoiceStatusLabel } from './types/InvoiceStatusLabel'; + +export const resolveInvoiceStatusMessage = (invoiceState: InvoiceState): InvoiceStatusLabel | null => { + const { isExpired, isAwaitingPayment, isUnderpaid, isPaidAwaitingConfirmations, isPaidAndConfirmed } = invoiceState; + + if (isPaidAndConfirmed) { + return 'Payment confirmed'; + } + + if (isPaidAwaitingConfirmations) { + return 'Awaiting confirmations'; + } + + if (isExpired && (isAwaitingPayment || isUnderpaid)) { + return 'Payment expired'; + } + + if (isUnderpaid) { + return 'Partial payment received'; + } + + if (isAwaitingPayment) { + return 'Awaiting payment'; + } + + return null; +}; diff --git a/backend/src/utils/invoice/resolveInvoiceStatusVariant.spec.ts b/backend/src/utils/invoice/resolveInvoiceStatusVariant.spec.ts new file mode 100644 index 0000000..30a877b --- /dev/null +++ b/backend/src/utils/invoice/resolveInvoiceStatusVariant.spec.ts @@ -0,0 +1,42 @@ +import { resolveInvoiceStatusVariant } from './resolveInvoiceStatusVariant'; +import type { InvoiceState } from './types/InvoiceState'; + +const baseState: InvoiceState = { + isAwaitingPayment: false, + isUnderpaid: false, + isPaidSufficient: false, + isPaidAwaitingConfirmations: false, + isPaidAndConfirmed: false, + isExpired: false, + hasPendingConfirmations: false +}; + +describe('resolveInvoiceStatusVariant', () => { + it('returns confirmed when payment is paid and confirmed', () => { + expect(resolveInvoiceStatusVariant({ ...baseState, isPaidAndConfirmed: true })).toBe('confirmed'); + }); + + it('returns awaiting-confirmations when paid but confirmations are pending', () => { + expect(resolveInvoiceStatusVariant({ ...baseState, isPaidAwaitingConfirmations: true })).toBe( + 'awaiting-confirmations' + ); + }); + + it('returns expired for unpaid expired invoices', () => { + expect( + resolveInvoiceStatusVariant({ ...baseState, isExpired: true, isAwaitingPayment: true }) + ).toBe('expired'); + }); + + it('returns underpaid for partial payments', () => { + expect(resolveInvoiceStatusVariant({ ...baseState, isUnderpaid: true })).toBe('underpaid'); + }); + + it('returns awaiting-payment when nothing was received', () => { + expect(resolveInvoiceStatusVariant({ ...baseState, isAwaitingPayment: true })).toBe('awaiting-payment'); + }); + + it('returns null for unrecognized combinations', () => { + expect(resolveInvoiceStatusVariant(baseState)).toBeNull(); + }); +}); diff --git a/backend/src/utils/invoice/resolveInvoiceStatusVariant.ts b/backend/src/utils/invoice/resolveInvoiceStatusVariant.ts new file mode 100644 index 0000000..cfa5888 --- /dev/null +++ b/backend/src/utils/invoice/resolveInvoiceStatusVariant.ts @@ -0,0 +1,28 @@ +import type { InvoiceState } from './types/InvoiceState'; +import type { InvoiceStatusVariant } from './types/InvoiceStatusVariant'; + +export const resolveInvoiceStatusVariant = (invoiceState: InvoiceState): InvoiceStatusVariant | null => { + const { isExpired, isAwaitingPayment, isUnderpaid, isPaidAwaitingConfirmations, isPaidAndConfirmed } = invoiceState; + + if (isPaidAndConfirmed) { + return 'confirmed'; + } + + if (isPaidAwaitingConfirmations) { + return 'awaiting-confirmations'; + } + + if (isExpired && (isAwaitingPayment || isUnderpaid)) { + return 'expired'; + } + + if (isUnderpaid) { + return 'underpaid'; + } + + if (isAwaitingPayment) { + return 'awaiting-payment'; + } + + return null; +}; diff --git a/backend/src/utils/invoice/sumInvoicePaymentAmountsAtomic.spec.ts b/backend/src/utils/invoice/sumInvoicePaymentAmountsAtomic.spec.ts new file mode 100644 index 0000000..8fbccbe --- /dev/null +++ b/backend/src/utils/invoice/sumInvoicePaymentAmountsAtomic.spec.ts @@ -0,0 +1,17 @@ +import { sumInvoicePaymentAmountsAtomic } from './sumInvoicePaymentAmountsAtomic'; + +describe('sumInvoicePaymentAmountsAtomic', () => { + it('returns zero when payments are missing or empty', () => { + expect(sumInvoicePaymentAmountsAtomic(undefined)).toBe('0'); + expect(sumInvoicePaymentAmountsAtomic([])).toBe('0'); + }); + + it('sums multiple payment amounts atomically', () => { + expect( + sumInvoicePaymentAmountsAtomic([ + { amountAtomic: '100000000000' }, + { amountAtomic: '250000000000' } + ]) + ).toBe('350000000000'); + }); +}); diff --git a/backend/src/utils/invoice/sumInvoicePaymentAmountsAtomic.ts b/backend/src/utils/invoice/sumInvoicePaymentAmountsAtomic.ts new file mode 100644 index 0000000..5751d0f --- /dev/null +++ b/backend/src/utils/invoice/sumInvoicePaymentAmountsAtomic.ts @@ -0,0 +1,9 @@ +import { addAtomic } from '../atomic/addAtomic'; + +export const sumInvoicePaymentAmountsAtomic = (payments: { amountAtomic: string }[] | undefined): string => { + if (!payments?.length) { + return '0'; + } + + return payments.reduce((total, payment) => addAtomic(total, payment.amountAtomic), '0'); +}; diff --git a/backend/src/utils/invoice/toStorefrontInvoiceView.spec.ts b/backend/src/utils/invoice/toStorefrontInvoiceView.spec.ts new file mode 100644 index 0000000..5690112 --- /dev/null +++ b/backend/src/utils/invoice/toStorefrontInvoiceView.spec.ts @@ -0,0 +1,664 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { XMR_ATOMIC_PER_XMR } from '../../consts/xmrAtomicPerXmr'; +import type { Invoice } from '../../modules/payment/entities/Invoice'; +import type { InvoiceMoneroDetails } from '../../modules/payment/entities/InvoiceMoneroDetails'; +import type { InvoicePayment } from '../../modules/payment/entities/InvoicePayment'; +import { PaymentMethod } from '../../modules/payment/types/PaymentMethod'; +import { InvoiceReason } from '../../modules/payment/types/InvoiceReason'; +import * as formatRelativeTimeAgoModule from '../formatRelativeTimeAgo'; +import * as generateQrCodeDataUrlModule from '../generateQrCodeDataUrl'; +import { toStorefrontInvoiceView } from './toStorefrontInvoiceView'; + +const oneXmrAtomic = XMR_ATOMIC_PER_XMR.toString(); +const paymentAddress = '4StorefrontInvoiceViewTestAddress'; + +const buildPayment = (overrides: Partial = {}): InvoicePayment => + ({ + txHash: 'default-tx-hash', + amountAtomic: oneXmrAtomic, + confirmations: 0, + createdAt: new Date('2026-01-01T12:00:00.000Z'), + ...overrides + }) as InvoicePayment; + +type MoneroDetailsOverrides = Partial< + Pick +>; + +const buildMoneroDetails = (overrides: MoneroDetailsOverrides = {}): InvoiceMoneroDetails => + ({ + paymentAddressIndex: 1, + fiatPerXmrAtCreation: 150, + requiredConfirmations: 1, + ...overrides + }) as InvoiceMoneroDetails; + +const buildInvoice = (overrides: Partial = {}): Invoice => + ({ + reason: InvoiceReason.Checkout, + paymentMethod: PaymentMethod.Xmr, + amountFiat: 42, + fiatCurrency: 'USD', + expiresAt: new Date('2099-06-15T12:05:30.000Z'), + paymentAddress, + expectedTotalAtomic: oneXmrAtomic, + payments: [], + moneroDetails: buildMoneroDetails(), + ...overrides + }) as Invoice; + +describe('toStorefrontInvoiceView', () => { + let generateQrCodeDataUrlSpy: jest.SpiedFunction; + let formatRelativeTimeAgoSpy: jest.SpiedFunction; + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-01T12:00:00.000Z')); + + generateQrCodeDataUrlSpy = jest + .spyOn(generateQrCodeDataUrlModule, 'generateQrCodeDataUrl') + .mockResolvedValue('data:image/png;base64,qr'); + + formatRelativeTimeAgoSpy = jest + .spyOn(formatRelativeTimeAgoModule, 'formatRelativeTimeAgo') + .mockReturnValue('2 hours ago'); + }); + + afterEach(() => { + generateQrCodeDataUrlSpy.mockRestore(); + formatRelativeTimeAgoSpy.mockRestore(); + jest.useRealTimers(); + }); + + describe('errors', () => { + it('throws when monero details are missing', async () => { + const invoice = buildInvoice({ moneroDetails: null }); + + await expect(toStorefrontInvoiceView(invoice)).rejects.toBeInstanceOf(InternalServerErrorException); + }); + + it('throws for unsupported payment methods', async () => { + const invoice = buildInvoice({ paymentMethod: 'btc' as PaymentMethod }); + + await expect(toStorefrontInvoiceView(invoice)).rejects.toThrow('Unsupported payment method: btc'); + }); + }); + + describe('awaiting payment', () => { + it('maps core invoice fields and awaiting-payment visibility', async () => { + const invoice = buildInvoice(); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view).toMatchObject({ + cryptoCurrency: 'XMR', + expectedTotalCrypto: '1.00000000', + receivedTotalCrypto: null, + paymentAddress, + qrCodeUrl: 'data:image/png;base64,qr', + instructionPrefix: 'Send exactly', + instructionAmountCrypto: '1.00000000', + instructionSuffix: 'to the address below.', + payments: [], + isPaidSufficient: false, + showStatusMessage: true, + statusMessage: 'Awaiting payment', + statusVariant: 'awaiting-payment', + showProminentAmount: true, + showExpectedTotal: false, + showReceivedTotal: false, + showInstruction: true, + showExpiry: true, + showQr: true, + showAddress: true, + showPayments: false, + showRefresh: true + }); + }); + + it('builds a qr code for the full expected amount', async () => { + const invoice = buildInvoice(); + + await toStorefrontInvoiceView(invoice); + + expect(generateQrCodeDataUrlSpy).toHaveBeenCalledWith(`monero:${paymentAddress}?tx_amount=1.00000000`); + }); + + it('formats expiry as seconds only when under one minute remains', async () => { + const invoice = buildInvoice({ + expiresAt: new Date('2026-06-01T12:00:45.000Z') + }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.expiresInDuration).toBe('45 seconds'); + }); + + it('formats expiry as minutes only when seconds are zero', async () => { + const invoice = buildInvoice({ + expiresAt: new Date('2026-06-01T12:05:00.000Z') + }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.expiresInDuration).toBe('5 minutes'); + }); + + it('formats expiry with minutes and seconds', async () => { + const invoice = buildInvoice({ + expiresAt: new Date('2026-06-01T12:05:30.000Z') + }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.expiresInDuration).toBe('5 minutes and 30 seconds'); + }); + + it('uses singular minute and second labels', async () => { + const invoice = buildInvoice({ + expiresAt: new Date('2026-06-01T12:01:01.000Z') + }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.expiresInDuration).toBe('1 minute and 1 second'); + }); + + it('clamps expired invoices to zero remaining seconds in the expiry label', async () => { + const invoice = buildInvoice({ + expiresAt: new Date('2020-01-01T00:00:00.000Z') + }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.expiresInDuration).toBeNull(); + expect(view.showRefresh).toBe(false); + }); + + it('formats long expiry durations using days and hours instead of thousands of minutes', async () => { + const invoice = buildInvoice({ + expiresAt: new Date('2026-06-03T11:52:12.000Z') + }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.expiresInDuration).toBe('1 day and 23 hours'); + }); + + it('formats sub-day expiry as hours and minutes', async () => { + const invoice = buildInvoice({ + expiresAt: new Date('2026-06-02T11:52:00.000Z') + }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.expiresInDuration).toBe('23 hours and 52 minutes'); + }); + }); + + describe('expired', () => { + it('maps expired awaiting-payment invoices without misleading payment capture UI', async () => { + const invoice = buildInvoice({ + expiresAt: new Date('2020-01-01T00:00:00.000Z') + }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view).toMatchObject({ + qrCodeUrl: null, + instructionPrefix: null, + instructionAmountCrypto: null, + instructionSuffix: null, + expiresInDuration: null, + statusMessage: 'Payment expired', + statusVariant: 'expired', + showProminentAmount: false, + showExpectedTotal: true, + showReceivedTotal: false, + showInstruction: false, + showExpiry: false, + showQr: false, + showAddress: false, + showPayments: false, + showRefresh: false + }); + expect(generateQrCodeDataUrlSpy).not.toHaveBeenCalled(); + }); + + it('still refreshes expired underpaid invoices while confirmations are pending', async () => { + const payment = buildPayment({ + txHash: 'partial-expired', + amountAtomic: '100000000000', + confirmations: 0 + }); + const invoice = buildInvoice({ + expiresAt: new Date('2020-01-01T00:00:00.000Z'), + payments: [payment] + }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view).toMatchObject({ + statusMessage: 'Payment expired', + statusVariant: 'expired', + receivedTotalCrypto: '0.10000000', + qrCodeUrl: null, + instructionPrefix: null, + showExpectedTotal: true, + showReceivedTotal: true, + showInstruction: false, + showQr: false, + showAddress: false, + showPayments: true, + showRefresh: true + }); + expect(generateQrCodeDataUrlSpy).not.toHaveBeenCalled(); + }); + + it('stops refresh on expired underpaid invoices once partial txs are confirmed', async () => { + const payment = buildPayment({ + txHash: 'partial-expired-confirmed', + amountAtomic: '100000000000', + confirmations: 10 + }); + const invoice = buildInvoice({ + expiresAt: new Date('2020-01-01T00:00:00.000Z'), + payments: [payment] + }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view).toMatchObject({ + statusMessage: 'Payment expired', + showPayments: true, + showRefresh: false + }); + }); + + it('keeps awaiting-confirmations status when an expired invoice is already paid sufficient', async () => { + const payment = buildPayment({ + txHash: 'paid-expired-unconfirmed', + confirmations: 0 + }); + const invoice = buildInvoice({ + expiresAt: new Date('2020-01-01T00:00:00.000Z'), + moneroDetails: buildMoneroDetails({ requiredConfirmations: 3 }), + payments: [payment] + }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view).toMatchObject({ + statusMessage: 'Awaiting confirmations', + showRefresh: true, + showInstruction: false, + showQr: false + }); + }); + }); + + describe('underpaid', () => { + it('maps partial payment totals, instruction, and visibility', async () => { + const payment = buildPayment({ + txHash: 'partial-tx', + amountAtomic: '100000000000', + confirmations: 10 + }); + const invoice = buildInvoice({ payments: [payment] }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view).toMatchObject({ + expectedTotalCrypto: '1.00000000', + receivedTotalCrypto: '0.10000000', + instructionPrefix: 'Send', + instructionAmountCrypto: '0.90000000', + instructionSuffix: 'more to the same address below.', + isPaidSufficient: false, + showProminentAmount: false, + showReceivedTotal: true, + showInstruction: true, + showQr: true, + showAddress: true, + showPayments: true, + showRefresh: true, + statusMessage: 'Partial payment received', + statusVariant: 'underpaid' + }); + }); + + it('rounds the remaining amount up when converting atomic to display units', async () => { + const payment = buildPayment({ amountAtomic: '999999999999' }); + const invoice = buildInvoice({ payments: [payment] }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.instructionAmountCrypto).toBe('0.00000001'); + expect(generateQrCodeDataUrlSpy).toHaveBeenCalledWith(`monero:${paymentAddress}?tx_amount=0.00000001`); + }); + + it('builds a qr code for the remaining amount', async () => { + const payment = buildPayment({ amountAtomic: '250000000000' }); + const invoice = buildInvoice({ payments: [payment] }); + + await toStorefrontInvoiceView(invoice); + + expect(generateQrCodeDataUrlSpy).toHaveBeenCalledWith(`monero:${paymentAddress}?tx_amount=0.75000000`); + }); + + it('hides the payments list when there are no payments yet', async () => { + const invoice = buildInvoice(); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.showPayments).toBe(false); + expect(view.payments).toEqual([]); + }); + }); + + describe('paid awaiting confirmations', () => { + it('maps paid-awaiting state and hides payment capture UI', async () => { + const payment = buildPayment({ + txHash: 'paid-unconfirmed', + confirmations: 0 + }); + const invoice = buildInvoice({ + moneroDetails: buildMoneroDetails({ requiredConfirmations: 3 }), + payments: [payment] + }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view).toMatchObject({ + receivedTotalCrypto: '1.00000000', + qrCodeUrl: null, + expiresInDuration: null, + instructionPrefix: null, + instructionAmountCrypto: null, + instructionSuffix: null, + isPaidSufficient: true, + showProminentAmount: false, + showExpectedTotal: true, + showReceivedTotal: true, + showInstruction: false, + showExpiry: false, + showQr: false, + showAddress: false, + showPayments: true, + showRefresh: true, + statusMessage: 'Awaiting confirmations', + statusVariant: 'awaiting-confirmations' + }); + expect(generateQrCodeDataUrlSpy).not.toHaveBeenCalled(); + }); + }); + + describe('paid and confirmed', () => { + it('maps confirmed state with collapsed payment capture UI', async () => { + const payment = buildPayment({ + txHash: 'confirmed-tx', + confirmations: 5 + }); + const invoice = buildInvoice({ payments: [payment] }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view).toMatchObject({ + receivedTotalCrypto: '1.00000000', + qrCodeUrl: null, + expiresInDuration: null, + instructionPrefix: null, + instructionAmountCrypto: null, + instructionSuffix: null, + isPaidSufficient: true, + showProminentAmount: false, + showExpectedTotal: true, + showReceivedTotal: true, + showInstruction: false, + showExpiry: false, + showQr: false, + showAddress: false, + showPayments: false, + showRefresh: false, + statusMessage: 'Payment confirmed', + statusVariant: 'confirmed' + }); + }); + + it('treats tx-detected invoices as confirmed for payment status display', async () => { + const payment = buildPayment({ confirmations: 0 }); + const invoice = buildInvoice({ + moneroDetails: buildMoneroDetails({ requiredConfirmations: 0 }), + payments: [payment] + }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.showRefresh).toBe(false); + expect(view.payments[0].confirmationStatus).toBe('Confirmed'); + }); + }); + + describe('overpayment', () => { + it('treats overpayment as paid sufficient without a qr code', async () => { + const payment = buildPayment({ + amountAtomic: '2000000000000', + confirmations: 10 + }); + const invoice = buildInvoice({ payments: [payment] }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.isPaidSufficient).toBe(true); + expect(view.receivedTotalCrypto).toBe('2.00000000'); + expect(view.qrCodeUrl).toBeNull(); + expect(generateQrCodeDataUrlSpy).not.toHaveBeenCalled(); + }); + }); + + describe('payment mapping', () => { + it('maps tx hash and converts atomic amount to display crypto', async () => { + const payment = buildPayment({ + txHash: 'abc123hash', + amountAtomic: '1500000000000', + confirmations: 0 + }); + const invoice = buildInvoice({ + moneroDetails: buildMoneroDetails({ requiredConfirmations: 2 }), + payments: [payment] + }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.payments).toEqual([ + { + txHash: 'abc123hash', + amountCrypto: '1.50000000', + confirmationStatus: '0 / 2 confirmations · detected 2 hours ago', + confirmationStatusVariant: 'confirming' + } + ]); + expect(formatRelativeTimeAgoSpy).toHaveBeenCalledWith(payment.createdAt); + }); + + it('sorts payments by detection time ascending', async () => { + const laterPayment = buildPayment({ + txHash: 'later', + createdAt: new Date('2026-01-02T12:00:00.000Z') + }); + const earlierPayment = buildPayment({ + txHash: 'earlier', + createdAt: new Date('2026-01-01T12:00:00.000Z') + }); + const invoice = buildInvoice({ + payments: [laterPayment, earlierPayment] + }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.payments.map(payment => payment.txHash)).toEqual(['earlier', 'later']); + }); + + it('marks a payment confirmed when confirmations meet the requirement', async () => { + const payment = buildPayment({ confirmations: 1 }); + const invoice = buildInvoice({ payments: [payment] }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.payments[0].confirmationStatus).toBe('Confirmed'); + expect(view.payments[0].confirmationStatusVariant).toBe('confirmed'); + expect(formatRelativeTimeAgoSpy).not.toHaveBeenCalled(); + }); + + it('marks a payment confirmed when confirmations exceed the requirement', async () => { + const payment = buildPayment({ confirmations: 99 }); + const invoice = buildInvoice({ + payments: [payment] + }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.payments[0].confirmationStatus).toBe('Confirmed'); + expect(view.payments[0].confirmationStatusVariant).toBe('confirmed'); + }); + + it('shows partial confirmation progress when below the requirement', async () => { + const payment = buildPayment({ confirmations: 2 }); + const invoice = buildInvoice({ + moneroDetails: buildMoneroDetails({ requiredConfirmations: 10 }), + payments: [payment] + }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.payments[0].confirmationStatus).toBe('2 / 10 confirmations · detected 2 hours ago'); + expect(view.payments[0].confirmationStatusVariant).toBe('confirming'); + }); + + it('shows zero confirmations against a non-zero requirement', async () => { + const payment = buildPayment({ confirmations: 0 }); + const invoice = buildInvoice({ + moneroDetails: buildMoneroDetails({ requiredConfirmations: 6 }), + payments: [payment] + }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.payments[0].confirmationStatus).toBe('0 / 6 confirmations · detected 2 hours ago'); + expect(view.payments[0].confirmationStatusVariant).toBe('confirming'); + }); + + it('maps each payment independently in a multi-payment invoice', async () => { + const firstPayment = buildPayment({ + txHash: 'first', + amountAtomic: '400000000000', + confirmations: 0, + createdAt: new Date('2026-01-01T10:00:00.000Z') + }); + const secondPayment = buildPayment({ + txHash: 'second', + amountAtomic: '600000000000', + confirmations: 1, + createdAt: new Date('2026-01-01T11:00:00.000Z') + }); + const invoice = buildInvoice({ + payments: [secondPayment, firstPayment] + }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.payments).toEqual([ + { + txHash: 'first', + amountCrypto: '0.40000000', + confirmationStatus: '0 / 1 confirmations · detected 2 hours ago', + confirmationStatusVariant: 'confirming' + }, + { + txHash: 'second', + amountCrypto: '0.60000000', + confirmationStatus: 'Confirmed', + confirmationStatusVariant: 'confirmed' + } + ]); + expect(view.isPaidSufficient).toBe(true); + expect(view.showRefresh).toBe(true); + expect(view.showPayments).toBe(true); + }); + + it('marks the invoice paid and confirmed only when every payment meets confirmations', async () => { + const firstPayment = buildPayment({ + txHash: 'first', + amountAtomic: '400000000000', + confirmations: 1, + createdAt: new Date('2026-01-01T10:00:00.000Z') + }); + const secondPayment = buildPayment({ + txHash: 'second', + amountAtomic: '600000000000', + confirmations: 3, + createdAt: new Date('2026-01-01T11:00:00.000Z') + }); + const invoice = buildInvoice({ + payments: [firstPayment, secondPayment] + }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.payments.every(payment => payment.confirmationStatus === 'Confirmed')).toBe(true); + expect(view.showRefresh).toBe(false); + expect(view.showPayments).toBe(false); + }); + + it('still shows confirmed status on underpaid partial txs', async () => { + const payment = buildPayment({ + amountAtomic: '100000000000', + confirmations: 10 + }); + const invoice = buildInvoice({ payments: [payment] }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.statusMessage).toBe('Partial payment received'); + expect(view.payments[0].confirmationStatus).toBe('Confirmed'); + expect(view.showPayments).toBe(true); + }); + + it('sums multiple underpaid payments before computing remaining amount', async () => { + const firstPayment = buildPayment({ + txHash: 'first', + amountAtomic: '100000000000' + }); + const secondPayment = buildPayment({ + txHash: 'second', + amountAtomic: '200000000000', + createdAt: new Date('2026-01-02T12:00:00.000Z') + }); + const invoice = buildInvoice({ payments: [firstPayment, secondPayment] }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.receivedTotalCrypto).toBe('0.30000000'); + expect(view.instructionAmountCrypto).toBe('0.70000000'); + expect(view.payments).toHaveLength(2); + }); + + it('handles an empty payments array', async () => { + const invoice = buildInvoice({ payments: [] }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.payments).toEqual([]); + expect(view.receivedTotalCrypto).toBeNull(); + }); + + it('handles a missing payments relation as empty', async () => { + const invoice = buildInvoice({ payments: undefined }); + + const view = await toStorefrontInvoiceView(invoice); + + expect(view.payments).toEqual([]); + expect(view.receivedTotalCrypto).toBeNull(); + }); + }); +}); diff --git a/backend/src/utils/invoice/toStorefrontInvoiceView.ts b/backend/src/utils/invoice/toStorefrontInvoiceView.ts new file mode 100644 index 0000000..4a8c3df --- /dev/null +++ b/backend/src/utils/invoice/toStorefrontInvoiceView.ts @@ -0,0 +1,206 @@ +import Decimal from 'decimal.js'; +import { InternalServerErrorException } from '@nestjs/common'; +import type { StorefrontInvoicePaymentView } from '../../modules/storefrontCore/types/StorefrontInvoicePaymentView'; +import type { StorefrontInvoiceView } from '../../modules/storefrontCore/types/StorefrontInvoiceView'; +import type { Invoice } from '../../modules/payment/entities/Invoice'; +import type { InvoicePayment } from '../../modules/payment/entities/InvoicePayment'; +import { PaymentMethod } from '../../modules/payment/types/PaymentMethod'; +import dayjs from '../../plugins/dayjs'; +import { generateQrCodeDataUrl } from '../generateQrCodeDataUrl'; +import { convertXmrAtomicToXmr } from '../monero/convertXmrAtomicToXmr'; +import { subtractAtomic } from '../atomic/subtractAtomic'; +import { deriveInvoiceState } from './deriveInvoiceState'; +import { formatInvoicePaymentConfirmationStatus } from './formatInvoicePaymentConfirmationStatus'; +import { resolveInvoiceStatusMessage } from './resolveInvoiceStatusMessage'; +import { resolveInvoiceStatusVariant } from './resolveInvoiceStatusVariant'; +import { resolveInvoiceRequiredConfirmations } from './resolveInvoiceRequiredConfirmations'; +import { sumInvoicePaymentAmountsAtomic } from './sumInvoicePaymentAmountsAtomic'; + +export const toStorefrontInvoiceView = async (invoice: Invoice): Promise => { + switch (invoice.paymentMethod) { + case PaymentMethod.Xmr: + return toXmrInvoiceView(invoice); + default: + throw new InternalServerErrorException(`Unsupported payment method: ${String(invoice.paymentMethod)}`); + } +}; + +const toXmrInvoiceView = async (invoice: Invoice): Promise => { + if (!invoice.moneroDetails) { + throw new InternalServerErrorException('Invoice is missing Monero payment details'); + } + + const invoiceState = deriveInvoiceState(invoice); + + const { + isAwaitingPayment, + isUnderpaid, + isPaidSufficient, + isPaidAwaitingConfirmations, + isPaidAndConfirmed, + isExpired, + hasPendingConfirmations + } = invoiceState; + + const cryptoCurrency = 'XMR'; + const expectedTotalCrypto = convertXmrAtomicToXmr(invoice.expectedTotalAtomic); + const receivedAtomic = sumInvoicePaymentAmountsAtomic(invoice.payments); + const receivedTotalCrypto = receivedAtomic === '0' ? null : convertXmrAtomicToXmr(receivedAtomic); + + const requiredConfirmations = resolveInvoiceRequiredConfirmations(invoice); + + const payments = [...(invoice.payments ?? [])] + .sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()) + .map(payment => toPaymentView(payment, requiredConfirmations)); + + const showPaymentCapture = (isAwaitingPayment || isUnderpaid) && !isExpired; + + const expiresInDuration = showPaymentCapture ? formatInvoiceExpiresInDuration(invoice.expiresAt) : null; + + let remainingTotalCrypto: string | null = null; + let instructionPrefix: string | null = null; + let instructionAmountCrypto: string | null = null; + let instructionSuffix: string | null = null; + let qrCodeUrl: string | null = null; + + if (showPaymentCapture) { + if (isAwaitingPayment) { + remainingTotalCrypto = expectedTotalCrypto; + instructionAmountCrypto = expectedTotalCrypto; + instructionPrefix = 'Send exactly'; + instructionSuffix = 'to the address below.'; + } else { + const remainingTotalCryptoAtomic = subtractAtomic(invoice.expectedTotalAtomic, receivedAtomic); + + remainingTotalCrypto = convertXmrAtomicToXmr(remainingTotalCryptoAtomic, Decimal.ROUND_CEIL); + instructionAmountCrypto = remainingTotalCrypto; + instructionPrefix = 'Send'; + instructionSuffix = 'more to the same address below.'; + } + + qrCodeUrl = await generateQrCodeDataUrl( + `monero:${invoice.paymentAddress}?tx_amount=${instructionAmountCrypto}` + ); + } + + const statusMessage = resolveInvoiceStatusMessage(invoiceState); + + const statusVariant = resolveInvoiceStatusVariant(invoiceState); + + return { + cryptoCurrency, + expectedTotalCrypto, + receivedTotalCrypto, + paymentAddress: invoice.paymentAddress, + qrCodeUrl, + instructionPrefix, + instructionAmountCrypto, + instructionSuffix, + expiresInDuration, + payments, + isPaidSufficient, + showStatusMessage: statusMessage !== null, + statusMessage, + statusVariant, + showProminentAmount: showPaymentCapture && isAwaitingPayment, + showExpectedTotal: + isPaidAwaitingConfirmations || isPaidAndConfirmed || (isExpired && (isAwaitingPayment || isUnderpaid)), + showReceivedTotal: isUnderpaid || isPaidAwaitingConfirmations || isPaidAndConfirmed, + showInstruction: showPaymentCapture, + showExpiry: showPaymentCapture, + showQr: showPaymentCapture, + showAddress: showPaymentCapture, + showPayments: (isUnderpaid || isPaidAwaitingConfirmations) && payments.length > 0, + showRefresh: resolveShowRefresh({ + isExpired, + isAwaitingPayment, + isUnderpaid, + isPaidAwaitingConfirmations, + isPaidAndConfirmed, + hasPendingConfirmations + }) + }; +}; + +const toPaymentView = (payment: InvoicePayment, requiredConfirmations: number): StorefrontInvoicePaymentView => { + const isConfirmed = payment.confirmations >= requiredConfirmations; + + return { + txHash: payment.txHash, + amountCrypto: convertXmrAtomicToXmr(payment.amountAtomic), + confirmationStatus: formatInvoicePaymentConfirmationStatus({ + confirmations: payment.confirmations, + requiredConfirmations, + createdAt: payment.createdAt, + format: 'extended' + }), + confirmationStatusVariant: isConfirmed ? 'confirmed' : 'confirming' + }; +}; + +const resolveShowRefresh = ({ + isExpired, + isAwaitingPayment, + isUnderpaid, + isPaidAwaitingConfirmations, + isPaidAndConfirmed, + hasPendingConfirmations +}: { + isExpired: boolean; + isAwaitingPayment: boolean; + isUnderpaid: boolean; + isPaidAwaitingConfirmations: boolean; + isPaidAndConfirmed: boolean; + hasPendingConfirmations: boolean; +}): boolean => { + if (isPaidAndConfirmed) { + return false; + } + + if (isPaidAwaitingConfirmations) { + return true; + } + + if (isExpired && isAwaitingPayment) { + return false; + } + + if (isExpired && isUnderpaid) { + return hasPendingConfirmations; + } + + return isAwaitingPayment || isUnderpaid; +}; + +const formatInvoiceExpiresInDuration = (expiresAt: Date): string => { + const totalSeconds = Math.max(0, dayjs(expiresAt).diff(dayjs(), 'second')); + + const days = Math.floor(totalSeconds / 86_400); + const hours = Math.floor((totalSeconds % 86_400) / 3_600); + const minutes = Math.floor((totalSeconds % 3_600) / 60); + const seconds = totalSeconds % 60; + + const parts: string[] = []; + + if (days > 0) { + parts.push(`${days} ${days === 1 ? 'day' : 'days'}`); + } + + if (hours > 0) { + parts.push(`${hours} ${hours === 1 ? 'hour' : 'hours'}`); + } + + if (minutes > 0) { + parts.push(`${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`); + } + + if (seconds > 0) { + parts.push(`${seconds} ${seconds === 1 ? 'second' : 'seconds'}`); + } + + if (parts.length === 0) { + return '0 seconds'; + } + + return parts.slice(0, 2).join(' and '); +}; diff --git a/backend/src/utils/invoice/types/InvoiceConfirmationsInput.ts b/backend/src/utils/invoice/types/InvoiceConfirmationsInput.ts new file mode 100644 index 0000000..886a5a8 --- /dev/null +++ b/backend/src/utils/invoice/types/InvoiceConfirmationsInput.ts @@ -0,0 +1,7 @@ +import type { PaymentMethod } from '../../../modules/payment/types/PaymentMethod'; + +export type InvoiceConfirmationsInput = { + paymentMethod: PaymentMethod; + payments?: { confirmations: number }[]; + moneroDetails?: { requiredConfirmations: number } | null; +}; diff --git a/backend/src/utils/invoice/types/InvoicePaymentConfirmationStatusFormat.ts b/backend/src/utils/invoice/types/InvoicePaymentConfirmationStatusFormat.ts new file mode 100644 index 0000000..2eab1e8 --- /dev/null +++ b/backend/src/utils/invoice/types/InvoicePaymentConfirmationStatusFormat.ts @@ -0,0 +1 @@ +export type InvoicePaymentConfirmationStatusFormat = 'compact' | 'extended'; diff --git a/backend/src/utils/invoice/types/InvoicePaymentConfirmationVariant.ts b/backend/src/utils/invoice/types/InvoicePaymentConfirmationVariant.ts new file mode 100644 index 0000000..8071485 --- /dev/null +++ b/backend/src/utils/invoice/types/InvoicePaymentConfirmationVariant.ts @@ -0,0 +1 @@ +export type InvoicePaymentConfirmationVariant = 'confirmed' | 'confirming'; diff --git a/backend/src/utils/invoice/types/InvoiceState.ts b/backend/src/utils/invoice/types/InvoiceState.ts new file mode 100644 index 0000000..8385737 --- /dev/null +++ b/backend/src/utils/invoice/types/InvoiceState.ts @@ -0,0 +1,9 @@ +export type InvoiceState = { + isAwaitingPayment: boolean; + isUnderpaid: boolean; + isPaidSufficient: boolean; + isPaidAwaitingConfirmations: boolean; + isPaidAndConfirmed: boolean; + isExpired: boolean; + hasPendingConfirmations: boolean; +}; diff --git a/backend/src/utils/invoice/types/InvoiceStateInput.ts b/backend/src/utils/invoice/types/InvoiceStateInput.ts new file mode 100644 index 0000000..3186702 --- /dev/null +++ b/backend/src/utils/invoice/types/InvoiceStateInput.ts @@ -0,0 +1,9 @@ +import type { PaymentMethod } from '../../../modules/payment/types/PaymentMethod'; + +export type InvoiceStateInput = { + paymentMethod: PaymentMethod; + expectedTotalAtomic: string; + expiresAt: Date; + payments?: { confirmations: number; amountAtomic: string }[]; + moneroDetails?: { requiredConfirmations: number } | null; +}; diff --git a/backend/src/utils/invoice/types/InvoiceStatusLabel.ts b/backend/src/utils/invoice/types/InvoiceStatusLabel.ts new file mode 100644 index 0000000..d0e56ad --- /dev/null +++ b/backend/src/utils/invoice/types/InvoiceStatusLabel.ts @@ -0,0 +1,6 @@ +export type InvoiceStatusLabel = + | 'Payment confirmed' + | 'Awaiting confirmations' + | 'Partial payment received' + | 'Payment expired' + | 'Awaiting payment'; diff --git a/backend/src/utils/invoice/types/InvoiceStatusVariant.ts b/backend/src/utils/invoice/types/InvoiceStatusVariant.ts new file mode 100644 index 0000000..fc95d98 --- /dev/null +++ b/backend/src/utils/invoice/types/InvoiceStatusVariant.ts @@ -0,0 +1,6 @@ +export type InvoiceStatusVariant = + | 'confirmed' + | 'awaiting-confirmations' + | 'expired' + | 'underpaid' + | 'awaiting-payment'; diff --git a/backend/src/utils/isSet.spec.ts b/backend/src/utils/isSet.spec.ts new file mode 100644 index 0000000..fe28caf --- /dev/null +++ b/backend/src/utils/isSet.spec.ts @@ -0,0 +1,15 @@ +import { isSet } from './isSet'; + +describe('isSet', () => { + it('returns false for null and undefined', () => { + expect(isSet(null)).toBe(false); + expect(isSet(undefined)).toBe(false); + }); + + it('returns true for present values including zero and empty string', () => { + expect(isSet(0)).toBe(true); + expect(isSet('')).toBe(true); + expect(isSet(false)).toBe(true); + expect(isSet(new Date('2026-01-01T00:00:00.000Z'))).toBe(true); + }); +}); diff --git a/backend/src/utils/isSet.ts b/backend/src/utils/isSet.ts new file mode 100644 index 0000000..6bc6c16 --- /dev/null +++ b/backend/src/utils/isSet.ts @@ -0,0 +1 @@ +export const isSet = (value: T | null | undefined): value is T => value !== null && value !== undefined; diff --git a/backend/src/utils/monero/convertFiatToXmr.spec.ts b/backend/src/utils/monero/convertFiatToXmr.spec.ts new file mode 100644 index 0000000..02f62d5 --- /dev/null +++ b/backend/src/utils/monero/convertFiatToXmr.spec.ts @@ -0,0 +1,11 @@ +import { convertFiatToXmr } from './convertFiatToXmr'; + +describe('convertFiatToXmr', () => { + it('formats small amounts without scientific notation', () => { + expect(convertFiatToXmr(0.003, 300_000)).toBe('0.00000001'); + }); + + it('converts fiat to XMR at the shop rate', () => { + expect(convertFiatToXmr(150, 300)).toBe('0.50000000'); + }); +}); diff --git a/backend/src/utils/monero/convertFiatToXmr.ts b/backend/src/utils/monero/convertFiatToXmr.ts new file mode 100644 index 0000000..46be15d --- /dev/null +++ b/backend/src/utils/monero/convertFiatToXmr.ts @@ -0,0 +1,5 @@ +import Decimal from 'decimal.js'; + +export const convertFiatToXmr = (fiatAmount: number, fiatPerXmr: number): string => { + return new Decimal(fiatAmount).div(fiatPerXmr).toDecimalPlaces(8, Decimal.ROUND_HALF_UP).toFixed(8); +}; diff --git a/backend/src/utils/monero/convertXmrAtomicToXmr.spec.ts b/backend/src/utils/monero/convertXmrAtomicToXmr.spec.ts new file mode 100644 index 0000000..6426c41 --- /dev/null +++ b/backend/src/utils/monero/convertXmrAtomicToXmr.spec.ts @@ -0,0 +1,16 @@ +import Decimal from 'decimal.js'; +import { convertXmrAtomicToXmr } from './convertXmrAtomicToXmr'; + +describe('convertXmrAtomicToXmr', () => { + it('formats small amounts without scientific notation', () => { + expect(convertXmrAtomicToXmr('10000', Decimal.ROUND_CEIL)).toBe('0.00000001'); + }); + + it('formats one XMR', () => { + expect(convertXmrAtomicToXmr('1000000000000')).toBe('1.00000000'); + }); + + it('rounds up remaining amounts when requested', () => { + expect(convertXmrAtomicToXmr('9070001', Decimal.ROUND_CEIL)).toBe('0.00000908'); + }); +}); diff --git a/backend/src/utils/monero/convertXmrAtomicToXmr.ts b/backend/src/utils/monero/convertXmrAtomicToXmr.ts new file mode 100644 index 0000000..0c5a45c --- /dev/null +++ b/backend/src/utils/monero/convertXmrAtomicToXmr.ts @@ -0,0 +1,9 @@ +import Decimal from 'decimal.js'; +import { XMR_ATOMIC_PER_XMR } from '../../consts/xmrAtomicPerXmr'; + +export const convertXmrAtomicToXmr = ( + amountAtomic: string, + rounding: Decimal.Rounding = Decimal.ROUND_HALF_UP +): string => { + return new Decimal(amountAtomic).div(XMR_ATOMIC_PER_XMR).toDecimalPlaces(8, rounding).toFixed(8); +}; diff --git a/backend/src/utils/monero/convertXmrToXmrAtomic.spec.ts b/backend/src/utils/monero/convertXmrToXmrAtomic.spec.ts new file mode 100644 index 0000000..f4431a6 --- /dev/null +++ b/backend/src/utils/monero/convertXmrToXmrAtomic.spec.ts @@ -0,0 +1,20 @@ +import { convertXmrToXmrAtomic } from './convertXmrToXmrAtomic'; + +describe('convertXmrToXmrAtomic', () => { + it('converts one XMR to atomic units', () => { + expect(convertXmrToXmrAtomic('1')).toBe('1000000000000'); + }); + + it('converts the smallest display unit to atomic units', () => { + expect(convertXmrToXmrAtomic('0.00000001')).toBe('10000'); + }); + + it('accepts scientific notation input', () => { + expect(convertXmrToXmrAtomic('1e-8')).toBe('10000'); + }); + + it('returns an integer string without scientific notation', () => { + expect(convertXmrToXmrAtomic('0.00025907')).toBe('259070000'); + expect(convertXmrToXmrAtomic('0.00025907')).not.toMatch(/e/i); + }); +}); diff --git a/backend/src/utils/monero/convertXmrToXmrAtomic.ts b/backend/src/utils/monero/convertXmrToXmrAtomic.ts new file mode 100644 index 0000000..2ac1547 --- /dev/null +++ b/backend/src/utils/monero/convertXmrToXmrAtomic.ts @@ -0,0 +1,6 @@ +import Decimal from 'decimal.js'; +import { XMR_ATOMIC_PER_XMR } from '../../consts/xmrAtomicPerXmr'; + +export const convertXmrToXmrAtomic = (amountXmr: string): string => { + return new Decimal(amountXmr).mul(XMR_ATOMIC_PER_XMR).toDecimalPlaces(0, Decimal.ROUND_HALF_UP).toString(); +}; diff --git a/backend/src/utils/monero/deduplicateIncomingMoneroTransfers.spec.ts b/backend/src/utils/monero/deduplicateIncomingMoneroTransfers.spec.ts new file mode 100644 index 0000000..c097b99 --- /dev/null +++ b/backend/src/utils/monero/deduplicateIncomingMoneroTransfers.spec.ts @@ -0,0 +1,16 @@ +import { deduplicateIncomingMoneroTransfers } from './deduplicateIncomingMoneroTransfers'; + +describe('deduplicateIncomingMoneroTransfers', () => { + it('keeps the transfer with the highest confirmations for each tx hash', () => { + const result = deduplicateIncomingMoneroTransfers([ + { txHash: 'tx-a', amountAtomic: '100', confirmations: 1, subaddrIndex: 3 }, + { txHash: 'tx-a', amountAtomic: '100', confirmations: 4, subaddrIndex: 3 }, + { txHash: 'tx-b', amountAtomic: '200', confirmations: 2, subaddrIndex: 7 } + ]); + + expect(result).toEqual([ + { txHash: 'tx-a', amountAtomic: '100', confirmations: 4, subaddrIndex: 3 }, + { txHash: 'tx-b', amountAtomic: '200', confirmations: 2, subaddrIndex: 7 } + ]); + }); +}); diff --git a/backend/src/utils/monero/deduplicateIncomingMoneroTransfers.ts b/backend/src/utils/monero/deduplicateIncomingMoneroTransfers.ts new file mode 100644 index 0000000..5fb317f --- /dev/null +++ b/backend/src/utils/monero/deduplicateIncomingMoneroTransfers.ts @@ -0,0 +1,17 @@ +import type { MoneroWalletRpcIncomingTransfer } from '../../modules/moneroWallet/types/MoneroWalletRpcIncomingTransfer'; + +export const deduplicateIncomingMoneroTransfers = ( + transfers: MoneroWalletRpcIncomingTransfer[] +): MoneroWalletRpcIncomingTransfer[] => { + const byTxHash = new Map(); + + for (const transfer of transfers) { + const existing = byTxHash.get(transfer.txHash); + + if (!existing || transfer.confirmations > existing.confirmations) { + byTxHash.set(transfer.txHash, transfer); + } + } + + return [...byTxHash.values()]; +}; diff --git a/backend/src/utils/monero/groupIncomingMoneroTransfersBySubaddrIndex.spec.ts b/backend/src/utils/monero/groupIncomingMoneroTransfersBySubaddrIndex.spec.ts new file mode 100644 index 0000000..d207cd3 --- /dev/null +++ b/backend/src/utils/monero/groupIncomingMoneroTransfersBySubaddrIndex.spec.ts @@ -0,0 +1,18 @@ +import { groupIncomingMoneroTransfersBySubaddrIndex } from './groupIncomingMoneroTransfersBySubaddrIndex'; + +describe('groupIncomingMoneroTransfersBySubaddrIndex', () => { + it('deduplicates and groups transfers by subaddress index', () => { + const grouped = groupIncomingMoneroTransfersBySubaddrIndex([ + { txHash: 'tx-a', amountAtomic: '100', confirmations: 1, subaddrIndex: 3 }, + { txHash: 'tx-a', amountAtomic: '100', confirmations: 4, subaddrIndex: 3 }, + { txHash: 'tx-b', amountAtomic: '200', confirmations: 2, subaddrIndex: 7 } + ]); + + expect(grouped.get(3)).toEqual([ + { txHash: 'tx-a', amountAtomic: '100', confirmations: 4, subaddrIndex: 3 } + ]); + expect(grouped.get(7)).toEqual([ + { txHash: 'tx-b', amountAtomic: '200', confirmations: 2, subaddrIndex: 7 } + ]); + }); +}); diff --git a/backend/src/utils/monero/groupIncomingMoneroTransfersBySubaddrIndex.ts b/backend/src/utils/monero/groupIncomingMoneroTransfersBySubaddrIndex.ts new file mode 100644 index 0000000..b23ad5b --- /dev/null +++ b/backend/src/utils/monero/groupIncomingMoneroTransfersBySubaddrIndex.ts @@ -0,0 +1,19 @@ +import type { MoneroWalletRpcIncomingTransfer } from '../../modules/moneroWallet/types/MoneroWalletRpcIncomingTransfer'; +import { deduplicateIncomingMoneroTransfers } from './deduplicateIncomingMoneroTransfers'; + +export const groupIncomingMoneroTransfersBySubaddrIndex = ( + transfers: MoneroWalletRpcIncomingTransfer[] +): Map => { + const deduped = deduplicateIncomingMoneroTransfers(transfers); + const grouped = new Map(); + + for (const transfer of deduped) { + const existing = grouped.get(transfer.subaddrIndex) ?? []; + + existing.push(transfer); + + grouped.set(transfer.subaddrIndex, existing); + } + + return grouped; +}; diff --git a/backend/src/utils/monero/incomingMoneroTransfers.spec.ts b/backend/src/utils/monero/incomingMoneroTransfers.spec.ts new file mode 100644 index 0000000..53166e7 --- /dev/null +++ b/backend/src/utils/monero/incomingMoneroTransfers.spec.ts @@ -0,0 +1,76 @@ +import { XMR_ATOMIC_PER_XMR } from '../../consts/xmrAtomicPerXmr'; +import type { MoneroWalletRpcIncomingTransfer } from '../../modules/moneroWallet/types/MoneroWalletRpcIncomingTransfer'; +import { deduplicateIncomingMoneroTransfers } from './deduplicateIncomingMoneroTransfers'; +import { groupIncomingMoneroTransfersBySubaddrIndex } from './groupIncomingMoneroTransfersBySubaddrIndex'; + +const oneXmrAtomic = XMR_ATOMIC_PER_XMR.toString(); + +const transfer = ( + overrides: Partial & Pick +): MoneroWalletRpcIncomingTransfer => ({ + amountAtomic: oneXmrAtomic, + confirmations: 1, + subaddrIndex: 1, + ...overrides +}); + +describe('deduplicateIncomingMoneroTransfers', () => { + it('keeps the entry with more confirmations for the same tx hash', () => { + const pending = transfer({ txHash: 'abc', confirmations: 0, amountAtomic: '100' }); + const confirmed = transfer({ txHash: 'abc', confirmations: 3, amountAtomic: '100' }); + + const deduped = deduplicateIncomingMoneroTransfers([pending, confirmed]); + + expect(deduped).toEqual([confirmed]); + }); + + it('keeps unrelated transfers', () => { + const first = transfer({ txHash: 'abc', subaddrIndex: 1 }); + const second = transfer({ txHash: 'def', subaddrIndex: 2 }); + + const deduped = deduplicateIncomingMoneroTransfers([first, second]); + + expect(deduped).toEqual([first, second]); + }); + + it('keeps one transfer when the same tx hash appears under different subaddrs', () => { + const first = transfer({ txHash: 'abc', subaddrIndex: 1, confirmations: 1 }); + const second = transfer({ txHash: 'abc', subaddrIndex: 2, confirmations: 2 }); + + const deduped = deduplicateIncomingMoneroTransfers([first, second]); + + expect(deduped).toEqual([second]); + }); +}); + +describe('groupIncomingMoneroTransfersBySubaddrIndex', () => { + it('groups transfers by subaddr index', () => { + const first = transfer({ txHash: 'abc', subaddrIndex: 1 }); + const second = transfer({ txHash: 'def', subaddrIndex: 2 }); + const third = transfer({ txHash: 'ghi', subaddrIndex: 1 }); + + const grouped = groupIncomingMoneroTransfersBySubaddrIndex([first, second, third]); + + expect(grouped).toEqual( + new Map([ + [1, [first, third]], + [2, [second]] + ]) + ); + }); + + it('deduplicates before grouping', () => { + const pending = transfer({ txHash: 'abc', subaddrIndex: 5, confirmations: 0 }); + const confirmed = transfer({ txHash: 'abc', subaddrIndex: 5, confirmations: 2 }); + + const grouped = groupIncomingMoneroTransfersBySubaddrIndex([pending, confirmed]); + + expect(grouped).toEqual(new Map([[5, [confirmed]]])); + }); + + it('returns an empty map for no transfers', () => { + const grouped = groupIncomingMoneroTransfersBySubaddrIndex([]); + + expect(grouped).toEqual(new Map()); + }); +}); diff --git a/backend/src/utils/monero/resolveMinConfirmations.spec.ts b/backend/src/utils/monero/resolveMinConfirmations.spec.ts new file mode 100644 index 0000000..3c7cc57 --- /dev/null +++ b/backend/src/utils/monero/resolveMinConfirmations.spec.ts @@ -0,0 +1,21 @@ +import { resolveMinConfirmations } from './resolveMinConfirmations'; + +const tiers = [ + { upToTotalFiat: '25', minConfirmations: 0 }, + { upToTotalFiat: '250', minConfirmations: 5 }, + { minConfirmations: 10 } +] as const; + +describe('resolveMinConfirmations', () => { + it('returns 0 for small orders (tx-detected tier)', () => { + expect(resolveMinConfirmations(10, [...tiers])).toBe(0); + }); + + it('returns the middle tier for medium orders', () => { + expect(resolveMinConfirmations(100, [...tiers])).toBe(5); + }); + + it('returns the catch-all tier for large orders', () => { + expect(resolveMinConfirmations(500, [...tiers])).toBe(10); + }); +}); diff --git a/backend/src/utils/monero/resolveMinConfirmations.ts b/backend/src/utils/monero/resolveMinConfirmations.ts new file mode 100644 index 0000000..b423736 --- /dev/null +++ b/backend/src/utils/monero/resolveMinConfirmations.ts @@ -0,0 +1,16 @@ +import Decimal from 'decimal.js'; +import type { MoneroConfirmationTier } from '../../types/MoneroConfirmationTier'; + +export const resolveMinConfirmations = (totalFiat: number, tiers: MoneroConfirmationTier[]): number => { + for (const tier of tiers) { + if (tier.upToTotalFiat === undefined) { + return tier.minConfirmations; + } + + if (new Decimal(totalFiat).lte(tier.upToTotalFiat)) { + return tier.minConfirmations; + } + } + + return tiers[tiers.length - 1].minConfirmations; +}; diff --git a/backend/src/utils/order/createOrderDetailQuery.spec.ts b/backend/src/utils/order/createOrderDetailQuery.spec.ts new file mode 100644 index 0000000..50e7b73 --- /dev/null +++ b/backend/src/utils/order/createOrderDetailQuery.spec.ts @@ -0,0 +1,25 @@ +import { createOrderDetailQuery } from './createOrderDetailQuery'; + +describe('createOrderDetailQuery', () => { + it('builds an order detail query with the expected joins and filter', () => { + const queryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getOne: jest.fn() + }; + + const orderRepo = { + createQueryBuilder: jest.fn().mockReturnValue(queryBuilder) + }; + + createOrderDetailQuery(orderRepo as never, 'order-1'); + + expect(orderRepo.createQueryBuilder).toHaveBeenCalledWith('order'); + expect(queryBuilder.leftJoinAndSelect).toHaveBeenCalledWith('order.lines', 'orderLine'); + expect(queryBuilder.leftJoinAndSelect).toHaveBeenCalledWith('order.checkoutInvoice', 'checkoutInvoice'); + expect(queryBuilder.where).toHaveBeenCalledWith('order.id = :orderId', { orderId: 'order-1' }); + }); +}); diff --git a/backend/src/utils/order/createOrderDetailQuery.ts b/backend/src/utils/order/createOrderDetailQuery.ts new file mode 100644 index 0000000..def4752 --- /dev/null +++ b/backend/src/utils/order/createOrderDetailQuery.ts @@ -0,0 +1,24 @@ +import type { Repository, SelectQueryBuilder } from 'typeorm'; +import { Order } from '../../modules/order/entities/Order'; + +export const createOrderDetailQuery = (orderRepo: Repository, orderId: string): SelectQueryBuilder => + orderRepo + .createQueryBuilder('order') + .leftJoinAndSelect('order.lines', 'orderLine') + .leftJoinAndSelect('orderLine.autoFulfillmentItems', 'autoFulfillmentItem') + .leftJoinAndSelect('autoFulfillmentItem.attachments', 'autoFulfillmentItemAttachment') + .leftJoinAndSelect('orderLine.manualFulfillment', 'manualFulfillment') + .leftJoinAndSelect('order.discounts', 'orderDiscount') + .leftJoinAndSelect('order.checkoutInvoice', 'checkoutInvoice') + .leftJoinAndSelect('checkoutInvoice.moneroDetails', 'checkoutMoneroDetails') + .leftJoinAndSelect('checkoutInvoice.payments', 'checkoutPayment') + .leftJoinAndSelect('order.shippingInvoice', 'shippingInvoice') + .leftJoinAndSelect('shippingInvoice.moneroDetails', 'shippingMoneroDetails') + .leftJoinAndSelect('shippingInvoice.payments', 'shippingPayment') + .leftJoinAndSelect('order.messages', 'message') + .addSelect('order.accessToken') + .addSelect('autoFulfillmentItem.contentSnapshot') + .addSelect('autoFulfillmentItemAttachment.storageKey') + .orderBy('message.createdAt', 'ASC') + .addOrderBy('autoFulfillmentItem.sortOrder', 'ASC') + .where('order.id = :orderId', { orderId }); diff --git a/backend/src/utils/order/deriveOrderState.spec.ts b/backend/src/utils/order/deriveOrderState.spec.ts new file mode 100644 index 0000000..72a6210 --- /dev/null +++ b/backend/src/utils/order/deriveOrderState.spec.ts @@ -0,0 +1,117 @@ +import { PaymentMethod } from '../../modules/payment/types/PaymentMethod'; +import { DeliveryMode } from '../../modules/product/types/DeliveryMode'; +import { ManualLineFulfillmentStatus } from '../../modules/order/types/ManualLineFulfillmentStatus'; +import { OrderFailureReason } from '../../modules/order/types/OrderFailureReason'; +import { OrderStatus } from '../../modules/order/types/OrderStatus'; +import { deriveOrderState } from './deriveOrderState'; + +const paidCheckoutInvoice = { + paymentMethod: PaymentMethod.Xmr, + amountFiat: 10, + expectedTotalAtomic: '1000', + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + payments: [{ amountAtomic: '1000', confirmations: 10 }], + moneroDetails: { requiredConfirmations: 1 } +}; + +const unpaidShippingInvoice = { + paymentMethod: PaymentMethod.Xmr, + amountFiat: 5, + expectedTotalAtomic: '500', + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + payments: [], + moneroDetails: { requiredConfirmations: 1 } +}; + +const paidShippingInvoice = { + paymentMethod: PaymentMethod.Xmr, + amountFiat: 5, + expectedTotalAtomic: '500', + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + payments: [{ amountAtomic: '500', confirmations: 10 }], + moneroDetails: { requiredConfirmations: 1 } +}; + +describe('deriveOrderState', () => { + it('treats failureReason as unfulfillable', () => { + const state = deriveOrderState({ + failureReason: OrderFailureReason.StockUnavailable, + checkoutInvoice: paidCheckoutInvoice, + lines: [] + }); + + expect(state.status).toBe(OrderStatus.Unfulfillable); + }); + + it('fulfills auto-only orders when checkout is paid and confirmed', () => { + const state = deriveOrderState({ + failureReason: null, + checkoutInvoice: paidCheckoutInvoice, + lines: [{ deliveryMode: DeliveryMode.Auto }] + }); + + expect(state.status).toBe(OrderStatus.Fulfilled); + }); + + it('stays unfulfilled when manual lines are still pending', () => { + const state = deriveOrderState({ + failureReason: null, + checkoutInvoice: paidCheckoutInvoice, + lines: [ + { + deliveryMode: DeliveryMode.Manual, + manualFulfillment: { status: ManualLineFulfillmentStatus.Pending } + } + ] + }); + + expect(state.status).toBe(OrderStatus.Unfulfilled); + }); + + it('fulfills manual orders when checkout is satisfied and lines are fulfilled', () => { + const state = deriveOrderState({ + failureReason: null, + checkoutInvoice: paidCheckoutInvoice, + lines: [ + { + deliveryMode: DeliveryMode.Manual, + manualFulfillment: { status: ManualLineFulfillmentStatus.Fulfilled } + } + ] + }); + + expect(state.status).toBe(OrderStatus.Fulfilled); + }); + + it('fulfills manual orders when paid shipping is satisfied and lines are fulfilled', () => { + const state = deriveOrderState({ + failureReason: null, + checkoutInvoice: paidCheckoutInvoice, + shippingInvoice: paidShippingInvoice, + lines: [ + { + deliveryMode: DeliveryMode.Manual, + manualFulfillment: { status: ManualLineFulfillmentStatus.Fulfilled } + } + ] + }); + + expect(state.status).toBe(OrderStatus.Fulfilled); + }); + + it('stays unfulfilled when a shipping invoice exists but is not paid and confirmed', () => { + const state = deriveOrderState({ + failureReason: null, + checkoutInvoice: paidCheckoutInvoice, + shippingInvoice: unpaidShippingInvoice, + lines: [ + { + deliveryMode: DeliveryMode.Manual, + manualFulfillment: { status: ManualLineFulfillmentStatus.Fulfilled } + } + ] + }); + + expect(state.status).toBe(OrderStatus.Unfulfilled); + }); +}); diff --git a/backend/src/utils/order/deriveOrderState.ts b/backend/src/utils/order/deriveOrderState.ts new file mode 100644 index 0000000..9f779a4 --- /dev/null +++ b/backend/src/utils/order/deriveOrderState.ts @@ -0,0 +1,45 @@ +import { deriveInvoiceState } from '../invoice/deriveInvoiceState'; +import type { InvoiceState } from '../invoice/types/InvoiceState'; +import { isSet } from '../isSet'; +import { DeliveryMode } from '../../modules/product/types/DeliveryMode'; +import { ManualLineFulfillmentStatus } from '../../modules/order/types/ManualLineFulfillmentStatus'; +import { OrderStatus } from '../../modules/order/types/OrderStatus'; +import type { OrderState } from './types/OrderState'; +import type { OrderStateInput } from './types/OrderStateInput'; + +export const deriveOrderState = (order: OrderStateInput): OrderState => { + const checkoutInvoiceState = order.checkoutInvoice ? deriveInvoiceState(order.checkoutInvoice) : null; + const shippingInvoiceState = order.shippingInvoice ? deriveInvoiceState(order.shippingInvoice) : null; + + return { + status: deriveOrderStatus(order, checkoutInvoiceState, shippingInvoiceState), + checkoutInvoiceState, + shippingInvoiceState + }; +}; + +const deriveOrderStatus = ( + order: OrderStateInput, + checkoutInvoiceState: InvoiceState | null, + shippingInvoiceState: InvoiceState | null +): OrderStatus => { + if (isSet(order.failureReason)) { + return OrderStatus.Unfulfillable; + } + + if (!checkoutInvoiceState?.isPaidAndConfirmed) { + return OrderStatus.Unfulfilled; + } + + if (order.shippingInvoice && !shippingInvoiceState?.isPaidAndConfirmed) { + return OrderStatus.Unfulfilled; + } + + const manualLines = (order.lines ?? []).filter(line => line.deliveryMode === DeliveryMode.Manual); + + const manualLinesFulfilled = manualLines.every( + line => line.manualFulfillment?.status === ManualLineFulfillmentStatus.Fulfilled + ); + + return manualLinesFulfilled ? OrderStatus.Fulfilled : OrderStatus.Unfulfilled; +}; diff --git a/backend/src/utils/order/deriveOrderTotals.spec.ts b/backend/src/utils/order/deriveOrderTotals.spec.ts new file mode 100644 index 0000000..3bf9beb --- /dev/null +++ b/backend/src/utils/order/deriveOrderTotals.spec.ts @@ -0,0 +1,113 @@ +import { deriveOrderTotals } from './deriveOrderTotals'; +import type { OrderTotalsInput } from './types/OrderTotalsInput'; + +const baseOrder = (overrides: Partial = {}): OrderTotalsInput => ({ + lines: [{ lineSubtotalFiat: 10 }], + discounts: [], + checkoutInvoice: { amountFiat: 10 }, + quotedAt: null, + shippingInvoice: null, + ...overrides +}); + +describe('deriveOrderTotals', () => { + it('returns null grand total until shipping is quoted', () => { + const totals = deriveOrderTotals(baseOrder()); + + expect(totals.totalFiat).toBe(10); + expect(totals.shippingCostFiat).toBeNull(); + expect(totals.grandTotalFiat).toBeNull(); + }); + + it('includes quoted shipping in grand total', () => { + const totals = deriveOrderTotals( + baseOrder({ + quotedAt: new Date('2026-01-01T12:00:00Z'), + shippingInvoice: { amountFiat: 5 } + }) + ); + + expect(totals.shippingCostFiat).toBe(5); + expect(totals.grandTotalFiat).toBe(15); + }); + + it('treats quoted free shipping as zero in grand total', () => { + const totals = deriveOrderTotals( + baseOrder({ + quotedAt: new Date('2026-01-01T12:00:00Z'), + shippingInvoice: null + }) + ); + + expect(totals.shippingCostFiat).toBe(0); + expect(totals.grandTotalFiat).toBe(10); + }); + + it('sums subtotal from multiple lines', () => { + const totals = deriveOrderTotals( + baseOrder({ + lines: [{ lineSubtotalFiat: 10 }, { lineSubtotalFiat: 25.5 }] + }) + ); + + expect(totals.subtotalFiat).toBe(35.5); + }); + + it('sums discount total', () => { + const totals = deriveOrderTotals( + baseOrder({ + discounts: [{ amountFiat: 2 }, { amountFiat: 3.5 }] + }) + ); + + expect(totals.discountTotalFiat).toBe(5.5); + }); + + it('uses checkout invoice amount for totalFiat', () => { + const totals = deriveOrderTotals( + baseOrder({ + lines: [{ lineSubtotalFiat: 100 }], + discounts: [{ amountFiat: 10 }], + checkoutInvoice: { amountFiat: 90 } + }) + ); + + expect(totals.subtotalFiat).toBe(100); + expect(totals.discountTotalFiat).toBe(10); + expect(totals.totalFiat).toBe(90); + }); + + it('defaults missing checkout invoice to totalFiat 0', () => { + const totals = deriveOrderTotals( + baseOrder({ + checkoutInvoice: null + }) + ); + + expect(totals.totalFiat).toBe(0); + }); + + it('defaults missing lines and discounts to zero', () => { + const totals = deriveOrderTotals( + baseOrder({ + lines: undefined, + discounts: undefined + }) + ); + + expect(totals.subtotalFiat).toBe(0); + expect(totals.discountTotalFiat).toBe(0); + }); + + it('rounds grand total to two decimal places', () => { + const totals = deriveOrderTotals( + baseOrder({ + checkoutInvoice: { amountFiat: 10.1 }, + quotedAt: new Date('2026-01-01T12:00:00Z'), + shippingInvoice: { amountFiat: 5.335 } + }) + ); + + expect(totals.grandTotalFiat).toBe(15.44); + }); +}); diff --git a/backend/src/utils/order/deriveOrderTotals.ts b/backend/src/utils/order/deriveOrderTotals.ts new file mode 100644 index 0000000..d2ea603 --- /dev/null +++ b/backend/src/utils/order/deriveOrderTotals.ts @@ -0,0 +1,23 @@ +import Decimal from 'decimal.js'; +import { sumByKey } from '../sumByKey'; +import { deriveShippingDeliveryCostFiat } from './deriveShippingDeliveryCostFiat'; +import type { OrderTotals } from './types/OrderTotals'; +import type { OrderTotalsInput } from './types/OrderTotalsInput'; + +export const deriveOrderTotals = (order: OrderTotalsInput): OrderTotals => { + const subtotalFiat = sumByKey(order.lines ?? [], 'lineSubtotalFiat'); + const discountTotalFiat = sumByKey(order.discounts ?? [], 'amountFiat'); + const totalFiat = order.checkoutInvoice?.amountFiat ?? 0; + const shippingCostFiat = deriveShippingDeliveryCostFiat(order); + + return { + subtotalFiat, + discountTotalFiat, + totalFiat, + shippingCostFiat, + grandTotalFiat: + shippingCostFiat === null + ? null + : new Decimal(totalFiat).plus(shippingCostFiat).toDecimalPlaces(2).toNumber() + }; +}; diff --git a/backend/src/utils/order/deriveShippingDeliveryCostFiat.spec.ts b/backend/src/utils/order/deriveShippingDeliveryCostFiat.spec.ts new file mode 100644 index 0000000..95c1984 --- /dev/null +++ b/backend/src/utils/order/deriveShippingDeliveryCostFiat.spec.ts @@ -0,0 +1,30 @@ +import { deriveShippingDeliveryCostFiat } from './deriveShippingDeliveryCostFiat'; + +describe('deriveShippingDeliveryCostFiat', () => { + it('returns null when shipping is not quoted yet', () => { + expect( + deriveShippingDeliveryCostFiat({ + quotedAt: null, + shippingInvoice: null + }) + ).toBeNull(); + }); + + it('returns 0 for free shipping quotes without an invoice', () => { + expect( + deriveShippingDeliveryCostFiat({ + quotedAt: new Date('2026-01-01T12:00:00Z'), + shippingInvoice: null + }) + ).toBe(0); + }); + + it('returns the shipping invoice amount when payment is required', () => { + expect( + deriveShippingDeliveryCostFiat({ + quotedAt: new Date('2026-01-01T12:00:00Z'), + shippingInvoice: { amountFiat: 5 } + }) + ).toBe(5); + }); +}); diff --git a/backend/src/utils/order/deriveShippingDeliveryCostFiat.ts b/backend/src/utils/order/deriveShippingDeliveryCostFiat.ts new file mode 100644 index 0000000..0d034b0 --- /dev/null +++ b/backend/src/utils/order/deriveShippingDeliveryCostFiat.ts @@ -0,0 +1,17 @@ +import { isSet } from '../isSet'; +import type { ShippingDeliveryCostInput } from './types/ShippingDeliveryCostInput'; + +export const deriveShippingDeliveryCostFiat = ({ + quotedAt, + shippingInvoice +}: ShippingDeliveryCostInput): number | null => { + if (!isSet(quotedAt)) { + return null; + } + + if (shippingInvoice) { + return shippingInvoice.amountFiat; + } + + return 0; +}; diff --git a/backend/src/utils/order/formatShortOrderId.spec.ts b/backend/src/utils/order/formatShortOrderId.spec.ts new file mode 100644 index 0000000..b089010 --- /dev/null +++ b/backend/src/utils/order/formatShortOrderId.spec.ts @@ -0,0 +1,7 @@ +import { formatShortOrderId } from './formatShortOrderId'; + +describe('formatShortOrderId', () => { + it('prefixes the first four characters of the order id', () => { + expect(formatShortOrderId('abcd-efgh-ijkl')).toBe('#abcd'); + }); +}); diff --git a/backend/src/utils/order/formatShortOrderId.ts b/backend/src/utils/order/formatShortOrderId.ts new file mode 100644 index 0000000..4314560 --- /dev/null +++ b/backend/src/utils/order/formatShortOrderId.ts @@ -0,0 +1,3 @@ +export function formatShortOrderId(orderId: string): string { + return `#${orderId.slice(0, 4)}`; +} diff --git a/backend/src/utils/order/types/OrderLineStateInput.ts b/backend/src/utils/order/types/OrderLineStateInput.ts new file mode 100644 index 0000000..ce5c964 --- /dev/null +++ b/backend/src/utils/order/types/OrderLineStateInput.ts @@ -0,0 +1,7 @@ +import type { DeliveryMode } from '../../../modules/product/types/DeliveryMode'; +import type { ManualLineFulfillmentStatus } from '../../../modules/order/types/ManualLineFulfillmentStatus'; + +export type OrderLineStateInput = { + deliveryMode: DeliveryMode; + manualFulfillment?: { status: ManualLineFulfillmentStatus } | null; +}; diff --git a/backend/src/utils/order/types/OrderState.ts b/backend/src/utils/order/types/OrderState.ts new file mode 100644 index 0000000..f999a36 --- /dev/null +++ b/backend/src/utils/order/types/OrderState.ts @@ -0,0 +1,8 @@ +import type { InvoiceState } from '../../invoice/types/InvoiceState'; +import type { OrderStatus } from '../../../modules/order/types/OrderStatus'; + +export type OrderState = { + status: OrderStatus; + checkoutInvoiceState: InvoiceState | null; + shippingInvoiceState: InvoiceState | null; +}; diff --git a/backend/src/utils/order/types/OrderStateInput.ts b/backend/src/utils/order/types/OrderStateInput.ts new file mode 100644 index 0000000..bcab4c5 --- /dev/null +++ b/backend/src/utils/order/types/OrderStateInput.ts @@ -0,0 +1,10 @@ +import type { InvoiceStateInput } from '../../invoice/types/InvoiceStateInput'; +import type { OrderFailureReason } from '../../../modules/order/types/OrderFailureReason'; +import type { OrderLineStateInput } from './OrderLineStateInput'; + +export type OrderStateInput = { + failureReason: OrderFailureReason | null; + checkoutInvoice?: InvoiceStateInput | null; + shippingInvoice?: InvoiceStateInput | null; + lines?: OrderLineStateInput[]; +}; diff --git a/backend/src/utils/order/types/OrderTotals.ts b/backend/src/utils/order/types/OrderTotals.ts new file mode 100644 index 0000000..7dc0414 --- /dev/null +++ b/backend/src/utils/order/types/OrderTotals.ts @@ -0,0 +1,7 @@ +export type OrderTotals = { + subtotalFiat: number; + discountTotalFiat: number; + totalFiat: number; + shippingCostFiat: number | null; + grandTotalFiat: number | null; +}; diff --git a/backend/src/utils/order/types/OrderTotalsInput.ts b/backend/src/utils/order/types/OrderTotalsInput.ts new file mode 100644 index 0000000..19f816a --- /dev/null +++ b/backend/src/utils/order/types/OrderTotalsInput.ts @@ -0,0 +1,7 @@ +import type { ShippingDeliveryCostInput } from './ShippingDeliveryCostInput'; + +export type OrderTotalsInput = ShippingDeliveryCostInput & { + lines?: Array<{ lineSubtotalFiat: number }>; + discounts?: Array<{ amountFiat: number }>; + checkoutInvoice?: { amountFiat: number } | null; +}; diff --git a/backend/src/utils/order/types/ShippingDeliveryCostInput.ts b/backend/src/utils/order/types/ShippingDeliveryCostInput.ts new file mode 100644 index 0000000..ce58d9a --- /dev/null +++ b/backend/src/utils/order/types/ShippingDeliveryCostInput.ts @@ -0,0 +1,4 @@ +export type ShippingDeliveryCostInput = { + quotedAt: Date | null; + shippingInvoice?: { amountFiat: number } | null; +}; diff --git a/backend/src/utils/removeFileFromDisk.ts b/backend/src/utils/removeFileFromDisk.ts new file mode 100644 index 0000000..2542d50 --- /dev/null +++ b/backend/src/utils/removeFileFromDisk.ts @@ -0,0 +1,10 @@ +import { Logger } from '@nestjs/common'; +import { unlink } from 'node:fs/promises'; + +export const removeFileFromDisk = async (path: string, logContext = 'removeFileFromDisk'): Promise => { + try { + await unlink(path); + } catch { + Logger.error(`Failed to remove file ${path}`, logContext); + } +}; diff --git a/backend/src/utils/safeInternalShopRedirectPath.spec.ts b/backend/src/utils/safeInternalShopRedirectPath.spec.ts new file mode 100644 index 0000000..9018d38 --- /dev/null +++ b/backend/src/utils/safeInternalShopRedirectPath.spec.ts @@ -0,0 +1,118 @@ +import type { Request } from 'express'; +import { safeInternalShopRedirectPath } from './safeInternalShopRedirectPath'; +import type { RequestOverrides } from './types/SafeInternalShopRedirectPathTestTypes'; + +const mockRequestGet = (impl: (name: string) => string | undefined): Request['get'] => + jest.fn(impl) as unknown as Request['get']; + +const buildRedirectTestRequest = (overrides: RequestOverrides = {}): Request => + ({ + protocol: 'https', + get: mockRequestGet(header => { + if (header === 'host') { + return 'shop.example.test'; + } + + if (header === 'referer') { + return 'https://shop.example.test/cart?tab=items'; + } + + return undefined; + }), + ...overrides + }) as Request; + +describe('safeInternalShopRedirectPath', () => { + it('returns the fallback when referer is missing', () => { + const req = buildRedirectTestRequest({ + get: mockRequestGet(() => undefined) + }); + + expect(safeInternalShopRedirectPath(req, '/cart')).toBe('/cart'); + }); + + it('returns same-origin pathname and search from referer', () => { + expect(safeInternalShopRedirectPath(buildRedirectTestRequest())).toBe('/cart?tab=items'); + }); + + it('returns pathname without search when referer has no query string', () => { + const req = buildRedirectTestRequest({ + get: mockRequestGet(header => { + if (header === 'host') { + return 'shop.example.test'; + } + + if (header === 'referer') { + return 'https://shop.example.test/checkout'; + } + + return undefined; + }) + }); + + expect(safeInternalShopRedirectPath(req)).toBe('/checkout'); + }); + + it('returns the fallback for cross-origin referers', () => { + const req = buildRedirectTestRequest({ + get: mockRequestGet(header => { + if (header === 'host') { + return 'shop.example.test'; + } + + if (header === 'referer') { + return 'https://evil.example/phish'; + } + + return undefined; + }) + }); + + expect(safeInternalShopRedirectPath(req)).toBe('/'); + }); + + it('returns the fallback when referer host matches but protocol differs', () => { + const req = buildRedirectTestRequest({ + protocol: 'http', + get: mockRequestGet(header => { + if (header === 'host') { + return 'shop.example.test'; + } + + if (header === 'referer') { + return 'https://shop.example.test/cart'; + } + + return undefined; + }) + }); + + expect(safeInternalShopRedirectPath(req, '/safe')).toBe('/safe'); + }); + + it('returns the fallback when referer uses a different port on the same host', () => { + const req = buildRedirectTestRequest({ + get: mockRequestGet(header => { + if (header === 'host') { + return 'shop.example.test'; + } + + if (header === 'referer') { + return 'https://shop.example.test:8443/admin'; + } + + return undefined; + }) + }); + + expect(safeInternalShopRedirectPath(req)).toBe('/'); + }); + + it('returns the fallback for malformed referer URLs', () => { + const req = buildRedirectTestRequest({ + get: mockRequestGet(header => (header === 'referer' ? 'not-a-url' : 'shop.example.test')) + }); + + expect(safeInternalShopRedirectPath(req, '/checkout')).toBe('/checkout'); + }); +}); diff --git a/backend/src/utils/safeInternalShopRedirectPath.ts b/backend/src/utils/safeInternalShopRedirectPath.ts new file mode 100644 index 0000000..b3fe6de --- /dev/null +++ b/backend/src/utils/safeInternalShopRedirectPath.ts @@ -0,0 +1,28 @@ +import type { Request } from 'express'; + +/** + * Same-origin `pathname + search` from `Referer`, otherwise `fallbackPath` (typically `/`). + * Convenience for Post/Redirect/Get: redirect back to where the POST came from without open redirects. + */ +export const safeInternalShopRedirectPath = (req: Request, fallbackPath: string = '/'): string => { + const referer = req.get('referer'); + + if (!referer) { + return fallbackPath; + } + + try { + const url = new URL(referer); + const expectedOrigin = `${req.protocol}://${req.get('host')}`; + + if (url.origin !== expectedOrigin) { + return fallbackPath; + } + + const pathWithQuery = `${url.pathname}${url.search}`; + + return pathWithQuery.length > 0 ? pathWithQuery : fallbackPath; + } catch { + return fallbackPath; + } +}; diff --git a/backend/src/utils/sanitizeUploadFilename.spec.ts b/backend/src/utils/sanitizeUploadFilename.spec.ts new file mode 100644 index 0000000..2a023ac --- /dev/null +++ b/backend/src/utils/sanitizeUploadFilename.spec.ts @@ -0,0 +1,17 @@ +import { sanitizeUploadFilename } from './sanitizeUploadFilename'; + +describe('sanitizeUploadFilename', () => { + it('strips path segments and unsafe characters', () => { + expect(sanitizeUploadFilename('../../evil name "file".pdf')).toBe('evil name file.pdf'); + }); + + it('returns file when the sanitized name is empty', () => { + expect(sanitizeUploadFilename(' ')).toBe('file'); + }); + + it('truncates very long filenames', () => { + const longName = `${'a'.repeat(300)}.pdf`; + + expect(sanitizeUploadFilename(longName).length).toBe(255); + }); +}); diff --git a/backend/src/utils/sanitizeUploadFilename.ts b/backend/src/utils/sanitizeUploadFilename.ts new file mode 100644 index 0000000..036dbef --- /dev/null +++ b/backend/src/utils/sanitizeUploadFilename.ts @@ -0,0 +1,22 @@ +import { basename } from 'node:path'; + +const MAX_UPLOAD_FILENAME_LENGTH = 255; + +export const sanitizeUploadFilename = (name: string): string => { + const base = basename(name.normalize('NFC')); + + const cleaned = base + // Unicode control characters (NUL, CR, LF, DEL, etc.) + .replace(/\p{Cc}/gu, '') + // Characters that break Content-Disposition quoted strings + .replace(/["\\]/g, '') + // Collapse runs of whitespace + .replace(/\s+/g, ' ') + .trim(); + + if (!cleaned) { + return 'file'; + } + + return cleaned.slice(0, MAX_UPLOAD_FILENAME_LENGTH); +}; diff --git a/backend/src/utils/shouldUseSecureCookie.spec.ts b/backend/src/utils/shouldUseSecureCookie.spec.ts new file mode 100644 index 0000000..94f8e6e --- /dev/null +++ b/backend/src/utils/shouldUseSecureCookie.spec.ts @@ -0,0 +1,24 @@ +import { SHOP_SURFACE_HEADER_NAME } from '../consts/shopSurfaceHeader'; +import { NodeEnv } from '../types/NodeEnv'; +import { ShopSurface } from '../types/ShopSurface'; +import { shouldUseSecureCookie } from './shouldUseSecureCookie'; + +describe('shouldUseSecureCookie', () => { + it('returns true only for production clearnet requests', () => { + const req = { headers: { [SHOP_SURFACE_HEADER_NAME]: ShopSurface.Clearnet } }; + + expect(shouldUseSecureCookie(NodeEnv.Production, req)).toBe(true); + }); + + it('returns false for production onion requests', () => { + const req = { headers: { [SHOP_SURFACE_HEADER_NAME]: ShopSurface.Onion } }; + + expect(shouldUseSecureCookie(NodeEnv.Production, req)).toBe(false); + }); + + it('returns false outside production', () => { + const req = { headers: { [SHOP_SURFACE_HEADER_NAME]: ShopSurface.Clearnet } }; + + expect(shouldUseSecureCookie(NodeEnv.Development, req)).toBe(false); + }); +}); diff --git a/backend/src/utils/shouldUseSecureCookie.ts b/backend/src/utils/shouldUseSecureCookie.ts new file mode 100644 index 0000000..a6f502b --- /dev/null +++ b/backend/src/utils/shouldUseSecureCookie.ts @@ -0,0 +1,10 @@ +import type { Request } from 'express'; +import { SHOP_SURFACE_HEADER_NAME } from '../consts/shopSurfaceHeader'; +import { NodeEnv } from '../types/NodeEnv'; +import { ShopSurface } from '../types/ShopSurface'; + +export const shouldUseSecureCookie = (nodeEnv: string | undefined, req: Pick): boolean => { + const raw = req.headers[SHOP_SURFACE_HEADER_NAME]; + + return nodeEnv === NodeEnv.Production && raw === ShopSurface.Clearnet; +}; diff --git a/backend/src/utils/sleep.ts b/backend/src/utils/sleep.ts new file mode 100644 index 0000000..17cc5a5 --- /dev/null +++ b/backend/src/utils/sleep.ts @@ -0,0 +1,4 @@ +export const sleep = (ms: number): Promise => + new Promise(resolve => { + setTimeout(resolve, ms); + }); diff --git a/backend/src/utils/storefront/toStorefrontDiscountView.spec.ts b/backend/src/utils/storefront/toStorefrontDiscountView.spec.ts new file mode 100644 index 0000000..a854dbd --- /dev/null +++ b/backend/src/utils/storefront/toStorefrontDiscountView.spec.ts @@ -0,0 +1,10 @@ +import { toStorefrontDiscountView } from './toStorefrontDiscountView'; + +describe('toStorefrontDiscountView', () => { + it('maps discount code and amount to the storefront view', () => { + expect(toStorefrontDiscountView({ code: 'SAVE10', amountFiat: 5 })).toEqual({ + code: 'SAVE10', + amountFiat: 5 + }); + }); +}); diff --git a/backend/src/utils/storefront/toStorefrontDiscountView.ts b/backend/src/utils/storefront/toStorefrontDiscountView.ts new file mode 100644 index 0000000..b4f2fef --- /dev/null +++ b/backend/src/utils/storefront/toStorefrontDiscountView.ts @@ -0,0 +1,10 @@ +import type { StorefrontDiscountView } from '../../modules/storefrontCore/types/StorefrontDiscountView'; +import type { StorefrontDiscountViewInput } from './types/StorefrontDiscountViewInput'; + +export const toStorefrontDiscountView = ({ + code, + amountFiat +}: StorefrontDiscountViewInput): StorefrontDiscountView => ({ + code, + amountFiat +}); diff --git a/backend/src/utils/storefront/types/StorefrontDiscountViewInput.ts b/backend/src/utils/storefront/types/StorefrontDiscountViewInput.ts new file mode 100644 index 0000000..2a16310 --- /dev/null +++ b/backend/src/utils/storefront/types/StorefrontDiscountViewInput.ts @@ -0,0 +1,4 @@ +export type StorefrontDiscountViewInput = { + code: string; + amountFiat: number; +}; diff --git a/backend/src/utils/sumByKey.spec.ts b/backend/src/utils/sumByKey.spec.ts new file mode 100644 index 0000000..3ef1b3d --- /dev/null +++ b/backend/src/utils/sumByKey.spec.ts @@ -0,0 +1,19 @@ +import { sumByKey } from './sumByKey'; + +describe('sumByKey', () => { + it('sums numeric values by key with two decimal places by default', () => { + const items = [{ amountFiat: 10.1 }, { amountFiat: 20.2 }]; + + expect(sumByKey(items, 'amountFiat')).toBe(30.3); + }); + + it('returns zero for empty collections', () => { + expect(sumByKey([] as { amountFiat: number }[], 'amountFiat')).toBe(0); + }); + + it('supports custom decimal places', () => { + const items = [{ qty: 1 }, { qty: 2 }]; + + expect(sumByKey(items, 'qty', 0)).toBe(3); + }); +}); diff --git a/backend/src/utils/sumByKey.ts b/backend/src/utils/sumByKey.ts new file mode 100644 index 0000000..1457479 --- /dev/null +++ b/backend/src/utils/sumByKey.ts @@ -0,0 +1,8 @@ +import Decimal from 'decimal.js'; +import type { NumericKeyOf } from './types/NumericKeyOf'; + +export const sumByKey = (items: readonly T[], key: NumericKeyOf, decimalPlaces = 2): number => + items + .reduce((sum, item) => sum.plus(item[key] as number), new Decimal(0)) + .toDecimalPlaces(decimalPlaces) + .toNumber(); diff --git a/backend/src/utils/toAbsoluteUrl.spec.ts b/backend/src/utils/toAbsoluteUrl.spec.ts new file mode 100644 index 0000000..2999ac9 --- /dev/null +++ b/backend/src/utils/toAbsoluteUrl.spec.ts @@ -0,0 +1,20 @@ +import { toAbsoluteUrl } from './toAbsoluteUrl'; + +describe('toAbsoluteUrl', () => { + const siteOrigin = 'https://shop.example'; + + it('returns null for empty input', () => { + expect(toAbsoluteUrl(siteOrigin, null)).toBeNull(); + expect(toAbsoluteUrl(siteOrigin, undefined)).toBeNull(); + }); + + it('returns absolute urls unchanged', () => { + expect(toAbsoluteUrl(siteOrigin, 'https://cdn.example/img.jpg')).toBe('https://cdn.example/img.jpg'); + expect(toAbsoluteUrl(siteOrigin, 'http://cdn.example/img.jpg')).toBe('http://cdn.example/img.jpg'); + }); + + it('prefixes site origin for relative paths', () => { + expect(toAbsoluteUrl(siteOrigin, '/uploads/thumb.jpg')).toBe('https://shop.example/uploads/thumb.jpg'); + expect(toAbsoluteUrl(siteOrigin, 'uploads/thumb.jpg')).toBe('https://shop.example/uploads/thumb.jpg'); + }); +}); diff --git a/backend/src/utils/toAbsoluteUrl.ts b/backend/src/utils/toAbsoluteUrl.ts new file mode 100644 index 0000000..e354f2d --- /dev/null +++ b/backend/src/utils/toAbsoluteUrl.ts @@ -0,0 +1,11 @@ +export const toAbsoluteUrl = (siteOrigin: string, url: string | null | undefined): string | null => { + if (!url) { + return null; + } + + if (url.startsWith('http://') || url.startsWith('https://')) { + return url; + } + + return `${siteOrigin}${url.startsWith('/') ? url : `/${url}`}`; +}; diff --git a/backend/src/utils/types/NumericKeyOf.ts b/backend/src/utils/types/NumericKeyOf.ts new file mode 100644 index 0000000..9424511 --- /dev/null +++ b/backend/src/utils/types/NumericKeyOf.ts @@ -0,0 +1,3 @@ +export type NumericKeyOf = { + [K in keyof T]: T[K] extends number ? K : never; +}[keyof T]; diff --git a/backend/src/utils/types/SafeInternalShopRedirectPathTestTypes.ts b/backend/src/utils/types/SafeInternalShopRedirectPathTestTypes.ts new file mode 100644 index 0000000..2c4bf40 --- /dev/null +++ b/backend/src/utils/types/SafeInternalShopRedirectPathTestTypes.ts @@ -0,0 +1,6 @@ +import type { Request } from 'express'; + +export type RequestOverrides = { + protocol?: string; + get?: Request['get']; +}; diff --git a/backend/src/validation/decorators/isBase64.ts b/backend/src/validation/decorators/isBase64.ts new file mode 100644 index 0000000..b2cdecf --- /dev/null +++ b/backend/src/validation/decorators/isBase64.ts @@ -0,0 +1,48 @@ +import { + Validate, + ValidatorConstraint, + type ValidationArguments, + type ValidatorConstraintInterface +} from 'class-validator'; +import type { IsBase64Options } from '../../types/validation/IsBase64Options'; + +const BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/; + +@ValidatorConstraint({ name: 'isBase64' }) +class IsBase64Constraint implements ValidatorConstraintInterface { + validate(value: unknown, args: ValidationArguments): boolean { + if (typeof value !== 'string' || !value) { + return false; + } + + if (!BASE64_PATTERN.test(value)) { + return false; + } + + const decoded = Buffer.from(value, 'base64'); + + if (decoded.length === 0) { + return false; + } + + const [{ byteLength }] = args.constraints as [IsBase64Options]; + + if (byteLength !== undefined) { + return decoded.length === byteLength; + } + + return true; + } + + defaultMessage(args: ValidationArguments): string { + const [{ byteLength }] = args.constraints as [IsBase64Options]; + + if (byteLength !== undefined) { + return `$property must be a base64-encoded ${byteLength}-byte value`; + } + + return '$property must be a valid base64 string'; + } +} + +export const IsBase64 = (options: IsBase64Options = {}) => Validate(IsBase64Constraint, [options]); diff --git a/backend/src/validation/decorators/isMoneroConfirmationTiers.spec.ts b/backend/src/validation/decorators/isMoneroConfirmationTiers.spec.ts new file mode 100644 index 0000000..307da47 --- /dev/null +++ b/backend/src/validation/decorators/isMoneroConfirmationTiers.spec.ts @@ -0,0 +1,85 @@ +import { validateSync } from 'class-validator'; +import { IsMoneroConfirmationTiers } from './isMoneroConfirmationTiers'; + +class TestDto { + @IsMoneroConfirmationTiers() + MONERO_CONFIRMATION_TIERS: string; +} + +const validateTiers = (value: string) => { + const dto = Object.assign(new TestDto(), { MONERO_CONFIRMATION_TIERS: value }); + + return validateSync(dto); +}; + +const validTiers = + '[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]'; + +describe('IsMoneroConfirmationTiers', () => { + it('accepts valid default tiers', () => { + expect(validateTiers(validTiers)).toHaveLength(0); + }); + + it('accepts numeric-only tiers without tx-detected (0)', () => { + const tiers = + '[{"upToTotalFiat":"25","minConfirmations":1},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]'; + + expect(validateTiers(tiers)).toHaveLength(0); + }); + + it('rejects empty string', () => { + expect(validateTiers('').length).toBeGreaterThan(0); + }); + + it('rejects invalid JSON', () => { + expect(validateTiers('not-json').length).toBeGreaterThan(0); + }); + + it('rejects empty array', () => { + expect(validateTiers('[]').length).toBeGreaterThan(0); + }); + + it('rejects tx-detected (0) more than once', () => { + const tiers = + '[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":0},{"minConfirmations":10}]'; + + expect(validateTiers(tiers).length).toBeGreaterThan(0); + }); + + it('rejects tx-detected (0) on catch-all tier', () => { + const tiers = '[{"upToTotalFiat":"25","minConfirmations":1},{"minConfirmations":0}]'; + + expect(validateTiers(tiers).length).toBeGreaterThan(0); + }); + + it('rejects legacy tx-detected string', () => { + const tiers = + '[{"upToTotalFiat":"25","minConfirmations":"tx-detected"},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]'; + + expect(validateTiers(tiers).length).toBeGreaterThan(0); + }); + + it('rejects missing upToTotalFiat on non-final tier', () => { + const tiers = '[{"minConfirmations":1},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]'; + + expect(validateTiers(tiers).length).toBeGreaterThan(0); + }); + + it('rejects non-positive upToTotalFiat on non-final tier', () => { + const tiers = '[{"upToTotalFiat":"0","minConfirmations":1},{"minConfirmations":10}]'; + + expect(validateTiers(tiers).length).toBeGreaterThan(0); + }); + + it('rejects negative minConfirmations', () => { + const tiers = '[{"upToTotalFiat":"25","minConfirmations":-1},{"minConfirmations":10}]'; + + expect(validateTiers(tiers).length).toBeGreaterThan(0); + }); + + it('rejects catch-all tier with upToTotalFiat', () => { + const tiers = '[{"upToTotalFiat":"25","minConfirmations":1},{"upToTotalFiat":"999","minConfirmations":10}]'; + + expect(validateTiers(tiers).length).toBeGreaterThan(0); + }); +}); diff --git a/backend/src/validation/decorators/isMoneroConfirmationTiers.ts b/backend/src/validation/decorators/isMoneroConfirmationTiers.ts new file mode 100644 index 0000000..5a09a10 --- /dev/null +++ b/backend/src/validation/decorators/isMoneroConfirmationTiers.ts @@ -0,0 +1,93 @@ +import { Validate, ValidatorConstraint, type ValidatorConstraintInterface } from 'class-validator'; +import type { MoneroConfirmationTier } from '../../types/MoneroConfirmationTier'; + +const isPositiveDecimalString = (value: string): boolean => { + const trimmed = value.trim(); + + if (!trimmed) { + return false; + } + + const amount = Number(trimmed); + + return Number.isFinite(amount) && amount > 0; +}; + +const isMinConfirmations = (value: unknown): boolean => + typeof value === 'number' && Number.isInteger(value) && value >= 0; + +const isMoneroConfirmationTier = (value: unknown): value is MoneroConfirmationTier => { + if (typeof value !== 'object' || value === null) { + return false; + } + + const tier = value as MoneroConfirmationTier; + + if (!isMinConfirmations(tier.minConfirmations)) { + return false; + } + + if (tier.upToTotalFiat === undefined) { + return true; + } + + return typeof tier.upToTotalFiat === 'string' && isPositiveDecimalString(tier.upToTotalFiat); +}; + +const isValidMoneroConfirmationTiersJson = (raw: string): boolean => { + let parsed: unknown; + + try { + parsed = JSON.parse(raw); + } catch { + return false; + } + + if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isMoneroConfirmationTier)) { + return false; + } + + const tiers = parsed; + const txDetectedTierCount = tiers.filter(tier => tier.minConfirmations === 0).length; + + if (txDetectedTierCount > 1) { + return false; + } + + const lastTier = tiers[tiers.length - 1]; + + if (lastTier.upToTotalFiat !== undefined) { + return false; + } + + if (lastTier.minConfirmations === 0) { + return false; + } + + for (let index = 0; index < tiers.length - 1; index++) { + const tier = tiers[index]; + + if (!tier.upToTotalFiat?.trim() || !isPositiveDecimalString(tier.upToTotalFiat)) { + return false; + } + } + + return true; +}; + +@ValidatorConstraint({ name: 'isMoneroConfirmationTiers' }) +class IsMoneroConfirmationTiersConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + if (typeof value !== 'string' || !value) { + return false; + } + + return isValidMoneroConfirmationTiersJson(value); + } + + defaultMessage(): string { + return '$property must be a non-empty JSON array of Monero confirmation tiers; minConfirmations must be 0 (tx-detected) or an integer >= 1, 0 may appear only once and not on the catch-all tier, non-final tiers need a positive upToTotalFiat in shop fiat currency, and the last tier must be a catch-all without upToTotalFiat'; + } +} + +export const IsMoneroConfirmationTiers = () => Validate(IsMoneroConfirmationTiersConstraint); diff --git a/backend/src/validation/decorators/isMoneroStandardAddress.spec.ts b/backend/src/validation/decorators/isMoneroStandardAddress.spec.ts new file mode 100644 index 0000000..cfcae33 --- /dev/null +++ b/backend/src/validation/decorators/isMoneroStandardAddress.spec.ts @@ -0,0 +1,184 @@ +import { validateSync } from 'class-validator'; +import { MoneroNetwork } from '../../types/MoneroNetwork'; +import { IsMoneroStandardAddress } from './isMoneroStandardAddress'; + +class TestDto { + @IsMoneroStandardAddress() + destinationAddress: string; +} + +const pad = (prefix: string) => `${prefix}${'A'.repeat(95 - prefix.length)}`; + +const MAINNET_STANDARD_ADDRESS = + '4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge'; + +const validateAddress = (value: string) => { + const dto = Object.assign(new TestDto(), { destinationAddress: value }); + + return validateSync(dto); +}; + +const accepts = (value: string) => { + expect(validateAddress(value)).toHaveLength(0); +}; + +const rejects = (value: string) => { + expect(validateAddress(value).length).toBeGreaterThan(0); +}; + +describe('IsMoneroStandardAddress', () => { + const originalNetwork = process.env.MONERO_NETWORK; + + afterEach(() => { + if (originalNetwork === undefined) { + delete process.env.MONERO_NETWORK; + } else { + process.env.MONERO_NETWORK = originalNetwork; + } + }); + + describe('mainnet', () => { + beforeEach(() => { + process.env.MONERO_NETWORK = MoneroNetwork.Mainnet; + }); + + it('accepts a real mainnet standard address', () => { + accepts(MAINNET_STANDARD_ADDRESS); + }); + + it('accepts mainnet standard addresses', () => { + accepts(pad('41')); + accepts(pad('49')); + accepts(pad('4A')); + accepts(pad('4B')); + }); + + it('accepts mainnet subaddresses', () => { + accepts(pad('82')); + accepts(pad('89')); + accepts(pad('8A')); + accepts(pad('8B')); + accepts(pad('8C')); + }); + + it('rejects stagenet addresses', () => { + rejects(pad('51')); + rejects(pad('72')); + }); + + it('rejects testnet addresses', () => { + rejects(pad('91')); + rejects(pad('BY')); + }); + }); + + describe('stagenet', () => { + beforeEach(() => { + process.env.MONERO_NETWORK = MoneroNetwork.Stagenet; + }); + + it('accepts stagenet standard addresses', () => { + accepts(pad('51')); + accepts(pad('59')); + accepts(pad('5A')); + accepts(pad('5B')); + }); + + it('accepts stagenet subaddresses', () => { + accepts(pad('72')); + accepts(pad('79')); + accepts(pad('7A')); + accepts(pad('7B')); + }); + + it('rejects mainnet addresses', () => { + rejects(MAINNET_STANDARD_ADDRESS); + rejects(pad('4A')); + rejects(pad('82')); + }); + + it('rejects testnet addresses', () => { + rejects(pad('91')); + rejects(pad('BY')); + }); + }); + + describe('shared validation rules', () => { + beforeEach(() => { + process.env.MONERO_NETWORK = MoneroNetwork.Mainnet; + }); + + it('accepts lowercase base58 characters in the body', () => { + accepts('4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge'); + }); + + it('trims surrounding whitespace on mainnet', () => { + accepts(` ${pad('4A')} `); + }); + + it('trims surrounding whitespace on stagenet', () => { + process.env.MONERO_NETWORK = MoneroNetwork.Stagenet; + + accepts(`\n${pad('5B')}\t`); + }); + + it.each(['0', '1', '2', '3', '6', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G'])( + 'rejects addresses starting with %s', + prefix => { + rejects(pad(`${prefix}A`)); + } + ); + + it.each(['0', 'C'])('rejects mainnet standard addresses with second character %s', second => { + rejects(pad(`4${second}`)); + }); + + it.each(['0', '1'])('rejects mainnet subaddresses with second character %s', second => { + rejects(pad(`8${second}`)); + }); + + it.each(['0', 'C'])('rejects stagenet standard addresses with second character %s', second => { + process.env.MONERO_NETWORK = MoneroNetwork.Stagenet; + + rejects(pad(`5${second}`)); + }); + + it.each(['0', '1', 'C'])('rejects stagenet subaddresses with second character %s', second => { + process.env.MONERO_NETWORK = MoneroNetwork.Stagenet; + + rejects(pad(`7${second}`)); + }); + + it('rejects empty input', () => { + rejects(''); + }); + + it('rejects whitespace-only input', () => { + rejects(' '); + }); + + it('rejects addresses that are too short', () => { + rejects(pad('4A').slice(0, 94)); + }); + + it('rejects addresses that are too long', () => { + rejects(`${pad('4A')}A`); + }); + + it('rejects integrated addresses (106 chars, also start with 4 on mainnet)', () => { + rejects(`${MAINNET_STANDARD_ADDRESS}${'A'.repeat(11)}`); + }); + + it.each(['0', 'O', 'I', 'l'])('rejects addresses containing %s', invalidChar => { + const address = `${pad('4A').slice(0, 10)}${invalidChar}${pad('4A').slice(11)}`; + + rejects(address); + }); + + it('returns a network-specific error message', () => { + const [error] = validateAddress('invalid'); + + expect(error.constraints?.isMoneroStandardAddress).toBe('Enter a valid mainnet Monero address.'); + }); + }); +}); diff --git a/backend/src/validation/decorators/isMoneroStandardAddress.ts b/backend/src/validation/decorators/isMoneroStandardAddress.ts new file mode 100644 index 0000000..10f57a2 --- /dev/null +++ b/backend/src/validation/decorators/isMoneroStandardAddress.ts @@ -0,0 +1,34 @@ +import { Validate, ValidatorConstraint, type ValidatorConstraintInterface } from 'class-validator'; +import { getMoneroWalletConfig } from '../../config'; +import { MoneroNetwork } from '../../types/MoneroNetwork'; + +// Standard + subaddress, 95 chars. Excludes integrated (106 chars). +// Prefix bytes: monero-project/monero src/cryptonote_config.h +// Regex shape: https://gist.github.com/masflam/84477ca88842e245dc7a4cc61ce299e3 +const BASE58 = '[1-9A-HJ-NP-Za-km-z]'; + +const NETWORK_ADDRESS_PATTERNS: Record = { + [MoneroNetwork.Mainnet]: new RegExp(`^(?:4[1-9AB]|8[2-9ABC])${BASE58}{93}$`), + [MoneroNetwork.Stagenet]: new RegExp(`^(?:5[1-9AB]|7[2-9AB])${BASE58}{93}$`) +}; + +@ValidatorConstraint({ name: 'isMoneroStandardAddress' }) +class IsMoneroStandardAddressConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + if (typeof value !== 'string') { + return false; + } + + const { network } = getMoneroWalletConfig(); + + return NETWORK_ADDRESS_PATTERNS[network].test(value.trim()); + } + + defaultMessage(): string { + const { network } = getMoneroWalletConfig(); + + return `Enter a valid ${network} Monero address.`; + } +} + +export const IsMoneroStandardAddress = () => Validate(IsMoneroStandardAddressConstraint); diff --git a/backend/src/validation/decorators/nullOr.ts b/backend/src/validation/decorators/nullOr.ts new file mode 100644 index 0000000..a1b788a --- /dev/null +++ b/backend/src/validation/decorators/nullOr.ts @@ -0,0 +1,92 @@ +import { applyDecorators } from '@nestjs/common'; +import { Type } from 'class-transformer'; +import { + Validate, + ValidatorConstraint, + type ValidationArguments, + type ValidationOptions, + type ValidatorConstraintInterface +} from 'class-validator'; +import type { NullOrIntOptions } from '../../types/validation/NullOrIntOptions'; +import type { NullOrNumberOptions } from '../../types/validation/NullOrNumberOptions'; + +@ValidatorConstraint({ name: 'nullOrDate' }) +class NullOrDateConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + if (value === null) { + return true; + } + + return value instanceof Date && !Number.isNaN(value.getTime()); + } + + defaultMessage(): string { + return '$property must be null or a valid date'; + } +} + +@ValidatorConstraint({ name: 'nullOrInt' }) +class NullOrIntConstraint implements ValidatorConstraintInterface { + validate(value: unknown, args: ValidationArguments): boolean { + if (value === null) { + return true; + } + + const [{ min }] = args.constraints as [NullOrIntOptions]; + + if (typeof value !== 'number' || !Number.isInteger(value)) { + return false; + } + + return min === undefined || value >= min; + } + + defaultMessage(args: ValidationArguments): string { + const [{ min }] = args.constraints as [NullOrIntOptions]; + + if (min !== undefined) { + return `$property must be null or an integer >= ${min}`; + } + + return '$property must be null or an integer'; + } +} + +@ValidatorConstraint({ name: 'nullOrNumber' }) +class NullOrNumberConstraint implements ValidatorConstraintInterface { + validate(value: unknown, args: ValidationArguments): boolean { + if (value === null) { + return true; + } + + const [{ min }] = args.constraints as [NullOrNumberOptions]; + + if (typeof value !== 'number' || !Number.isFinite(value)) { + return false; + } + + return min === undefined || value >= min; + } + + defaultMessage(args: ValidationArguments): string { + const [{ min }] = args.constraints as [NullOrNumberOptions]; + + if (min !== undefined) { + return `$property must be null or a number >= ${min}`; + } + + return '$property must be null or a number'; + } +} + +export const NullOrDate = (validationOptions?: ValidationOptions) => + applyDecorators( + Type(() => Date), + Validate(NullOrDateConstraint, validationOptions) + ); + +export const NullOrInt = (options?: NullOrIntOptions, validationOptions?: ValidationOptions) => + applyDecorators(Validate(NullOrIntConstraint, [options ?? {}], validationOptions)); + +export const NullOrNumber = (options?: NullOrNumberOptions, validationOptions?: ValidationOptions) => + applyDecorators(Validate(NullOrNumberConstraint, [options ?? {}], validationOptions)); diff --git a/backend/tsconfig.build.json b/backend/tsconfig.build.json new file mode 100644 index 0000000..aed3485 --- /dev/null +++ b/backend/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..2b1fd3f --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "resolvePackageJsonExports": true, + "esModuleInterop": true, + "isolatedModules": true, + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2023", + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "types": ["node", "jest"], + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "strictPropertyInitialization": false, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": false, + "strictBindCallApply": false, + "noFallthroughCasesInSwitch": false + } +} diff --git a/cms/.dockerignore b/cms/.dockerignore new file mode 100644 index 0000000..7abceb9 --- /dev/null +++ b/cms/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +.gitignore +Dockerfile* +npm-debug.log diff --git a/cms/Dockerfile.dev b/cms/Dockerfile.dev new file mode 100644 index 0000000..52d8d62 --- /dev/null +++ b/cms/Dockerfile.dev @@ -0,0 +1,11 @@ +FROM node:20-alpine + +WORKDIR /cms + +COPY package*.json . + +RUN npm ci + +ADD . . + +CMD ["npm", "run", "dev"] diff --git a/cms/index.html b/cms/index.html new file mode 100644 index 0000000..b01fd61 --- /dev/null +++ b/cms/index.html @@ -0,0 +1,13 @@ + + + + + + + cms + + +
    + + + diff --git a/cms/package-lock.json b/cms/package-lock.json new file mode 100644 index 0000000..d0ce2e6 --- /dev/null +++ b/cms/package-lock.json @@ -0,0 +1,3649 @@ +{ + "name": "cms", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cms", + "version": "0.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "@tiptap/extension-link": "^3.22.5", + "@tiptap/extension-placeholder": "^3.22.5", + "@tiptap/starter-kit": "^3.22.5", + "@tiptap/vue-3": "^3.22.5", + "axios": "^1.15.1", + "dayjs": "^1.11.19", + "decimal.js": "^10.6.0", + "element-plus": "^2.13.6", + "pinia": "^3.0.4", + "pretty-bytes": "^7.1.0", + "vue": "^3.5.32", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@types/node": "^24.12.2", + "@vitejs/plugin-vue": "^6.0.5", + "@vue/tsconfig": "^0.9.1", + "sass-embedded": "^1.99.0", + "typescript": "~6.0.2", + "unplugin-vue-components": "^32.0.0", + "vite": "^8.0.4", + "vue-tsc": "^3.2.6" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", + "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", + "dev": true, + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tiptap/core": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz", + "integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.22.5.tgz", + "integrity": "sha512-ajyP5W8fG5Hrru47T/eF3xMKOpNvWofgNJqBTeNuGl02sYxsy9a4EunyFxudsaZP9WW3VOD4SaIWr5+MqpbnOQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.22.5.tgz", + "integrity": "sha512-l/uDtpJISiFFyfctvnODNWBN/XPZI1jVZRacTRDDnSn8+x6KQ7G2qgFYueU7KvVJGDFVT39Iio56mcFRG/Pozg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.22.5.tgz", + "integrity": "sha512-yrNlFQQJY5MmhBpmD8tnmaSmyUQrEvgyPKa3bzVeWEhDSG1CW4A0ZSMx3hrA9yFO0HWfw3IJmvSCycEZQBalpQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.22.5.tgz", + "integrity": "sha512-cf54fG9AybU8NgPMv1TOcoqAkELeRc/VpnSCt/rIJZphWQx9nsFmrtkrlCatrIcCaGtNZYwlHlMnC5LVVMu0uA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.22.5" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.22.5.tgz", + "integrity": "sha512-mwDNOJC9rYbDu/JcqrN4dbUQRklJU8Fuk2raxD/IvFw9qUIcPCmxQ2XT9UTKmZz/Ju7Kdy72fss6XpgWv6gLAQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.5.tgz", + "integrity": "sha512-d123kCfLdJTi4fue1m0+TNFztDkmIRSZGZmGu6H9KqwG5Q7IzjT9o8lzRsz+pXxYqHvqgYmXoEpM6srbzXx/Ag==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.22.5.tgz", + "integrity": "sha512-8NJERd+pCtvSuEP4C4WMGYmRRCV12ePZL7bC+QUdFlbdXg+kNZS0zZ7hh879tYA0Kidbi8rWWD1Tx+H2ezkmMw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.22.5.tgz", + "integrity": "sha512-Mp40DaFrY3sEUVtFqmxrR0BmU4G3k8GCYYNGqNa9OqWv7BrcFDC03V2n3okESDKt4MKkzhQQmypq+ouLy8dLfA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.22.5" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.22.5.tgz", + "integrity": "sha512-dhem4sTPhyQgQ+pFp2Oud4k4FSQz9PVMgeQAC9288SmGwxBkJNveDAw6sKTMrumqDvwkJrtslXIupq9TZYQnzg==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.22.5.tgz", + "integrity": "sha512-4WkMu7qqjbsm8hCQS+8X+la1wjriN0SKoRdvpfKH33qM50MB34tYJuGLAO+y7TTh4MMMco3AZCKPBL5JVMqNIg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.22.5" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.22.5.tgz", + "integrity": "sha512-n0R2mUVYZU2AVbJhg/WcY9+zx690wVwvsItHJf0DrYbf1tCYHx+PRHUt/AoXk6u8BSmnkb8/FDziS8m3mjfpSg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.22.5.tgz", + "integrity": "sha512-hjyEG4947PAhMBfP1G6B0QAh6+y9mp2C5BQmNjprA05/lQzDAT7KFZzNh8ZVp3ol6aICKq/N1gFOW9Dc/9FUOw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.22.5.tgz", + "integrity": "sha512-vUV0/ugIbXOc8SJib0h8UMhgcqZXWu/dkEhlswZN4VVven1o5enkfxEiDw+OyIJHi5rUkrdhsQ/KTxG/Xb7X8A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.22.5.tgz", + "integrity": "sha512-4T8baSiLkeIymTgEwirxDFt5YgYofkP3m1+MGYdGy2HKcOK+1vpvlPhEO1X5qtZngtJW5S4+njKjinRg52A4PA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.22.5.tgz", + "integrity": "sha512-d671MvF3GPKoS2OVxjIlQ7hIE7MS3hREdR+d4cvnnoiLLD+ZJ6KgDnxmWqF0a1s4qxLWK2KxKRSOIfYGE31QWQ==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz", + "integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.22.5.tgz", + "integrity": "sha512-W7uTmyKLhlsvuTPLv+8WwnsY+mlikBFIoLSvVcBaFt4MwpsZ+DeB6KQg02Y7tbtaAnG7rXu9Fvw2QORh2P728A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.22.5" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.22.5.tgz", + "integrity": "sha512-cGUnxJ0y515e1bVHNjUmbx7oWHoEon59w6BA5N2KwV9iW2mZZchlTX4yxJSOX+ixeVRChsa7YwC3Z1jUZ6AMEg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.22.5" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.22.5.tgz", + "integrity": "sha512-OXdh4k4CNrukwiSdWdEQ49uvgnqvR0Z9aNSP4HI5/kZQ/Te1NtRtYCpUrzWyO/7CtjcCisXHti0o9C/TV8YMbQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.22.5" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.22.5.tgz", + "integrity": "sha512-52KCto4+XKpnBWpIufspWLyq4UWxAWC72ANPdGuIhbi72NRTabiTbTVN40uwGSPkyakeESG0/vKdWJCVvB4f0g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.22.5.tgz", + "integrity": "sha512-MZAohQ3FCS763BkhGXgaWRya6WruZjwRwEAkXP8vkxbERzl2OJRjniS4uXCWzAlRb3ttE103SnY7LMdM8FvsXw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.22.5" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.22.5.tgz", + "integrity": "sha512-42WrrFK5gOom/0znH85x12Mw5IQ/6O6DWdyUWoRIrNA/qJpuHtU8oVU+bIgU2tuomMGHruRjIzgBQv5sBjEtww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.5.tgz", + "integrity": "sha512-bzpDOdAEo1JeoVZDIyV0oY0jGXkEG+AzF70SzHoRSjOvFDtKWunyXf9eO1OnOr2/fmMcckT2qwUBNBMQplWBzw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.22.5.tgz", + "integrity": "sha512-9ut09rJD0iEbS6sk7yd2j6IwuFDLTNmDEGTDLodvqAfi+bq7ddsTDv0YviXoZaA9sdHAdTEVr2ITy2m6WK5jpA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz", + "integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz", + "integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-keymap": "^1.2.2", + "prosemirror-model": "^1.24.1", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.38.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.22.5.tgz", + "integrity": "sha512-LZ/LYbwH6rnDi5DnRyagkuNsYAVyhM+yJvvz+ZuYA0JkPiTXJV86J5PWSKew8M0gVfMHcNVtKjfQCvViFCeIgw==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.22.5", + "@tiptap/extension-blockquote": "^3.22.5", + "@tiptap/extension-bold": "^3.22.5", + "@tiptap/extension-bullet-list": "^3.22.5", + "@tiptap/extension-code": "^3.22.5", + "@tiptap/extension-code-block": "^3.22.5", + "@tiptap/extension-document": "^3.22.5", + "@tiptap/extension-dropcursor": "^3.22.5", + "@tiptap/extension-gapcursor": "^3.22.5", + "@tiptap/extension-hard-break": "^3.22.5", + "@tiptap/extension-heading": "^3.22.5", + "@tiptap/extension-horizontal-rule": "^3.22.5", + "@tiptap/extension-italic": "^3.22.5", + "@tiptap/extension-link": "^3.22.5", + "@tiptap/extension-list": "^3.22.5", + "@tiptap/extension-list-item": "^3.22.5", + "@tiptap/extension-list-keymap": "^3.22.5", + "@tiptap/extension-ordered-list": "^3.22.5", + "@tiptap/extension-paragraph": "^3.22.5", + "@tiptap/extension-strike": "^3.22.5", + "@tiptap/extension-text": "^3.22.5", + "@tiptap/extension-underline": "^3.22.5", + "@tiptap/extensions": "^3.22.5", + "@tiptap/pm": "^3.22.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/vue-3": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/vue-3/-/vue-3-3.22.5.tgz", + "integrity": "sha512-xwSXPwDjauIVktMXBMaNaSgFyq3O1sXcX1vWyHyyCFlq4+8ekq4uXbjkD6y6IhZyr/AQoRYnjgosus+apGyGuA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.22.5", + "@tiptap/extension-floating-menu": "^3.22.5" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5", + "vue": "^3.0.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz", + "integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.6.tgz", + "integrity": "sha512-xYYYX3/aVup576tP/23sEUpgiEnujrENaoNRbaozC1/MA9I6EGFQRJb4xrt/MmUCAGlxTKL2RmT8JLTPqagCkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.9.1.tgz", + "integrity": "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">= 5.8", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/colorjs.io": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", + "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.13.6", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.6.tgz", + "integrity": "sha512-XHgwXr8Fjz6i+6BaqFhAbae/dJbG7bBAAlHrY3pWL7dpj+JcqcOyKYt4Oy5KP86FQwS1k4uIZDjCx2FyUR5lDg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.4" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", + "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia/node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-bytes": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-7.1.0.tgz", + "integrity": "sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.4", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", + "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.8", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz", + "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/sass": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz", + "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass-embedded": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.99.0.tgz", + "integrity": "sha512-gF/juR1aX02lZHkvwxdF80SapkQeg2fetoDF6gIQkNbSw5YEUFspMkyGTjPjgZSgIHuZpy+Wz4PlebKnLXMjdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bufbuild/protobuf": "^2.5.0", + "colorjs.io": "^0.5.0", + "immutable": "^5.1.5", + "rxjs": "^7.4.0", + "supports-color": "^8.1.1", + "sync-child-process": "^1.0.2", + "varint": "^6.0.0" + }, + "bin": { + "sass": "dist/bin/sass.js" + }, + "engines": { + "node": ">=16.0.0" + }, + "optionalDependencies": { + "sass-embedded-all-unknown": "1.99.0", + "sass-embedded-android-arm": "1.99.0", + "sass-embedded-android-arm64": "1.99.0", + "sass-embedded-android-riscv64": "1.99.0", + "sass-embedded-android-x64": "1.99.0", + "sass-embedded-darwin-arm64": "1.99.0", + "sass-embedded-darwin-x64": "1.99.0", + "sass-embedded-linux-arm": "1.99.0", + "sass-embedded-linux-arm64": "1.99.0", + "sass-embedded-linux-musl-arm": "1.99.0", + "sass-embedded-linux-musl-arm64": "1.99.0", + "sass-embedded-linux-musl-riscv64": "1.99.0", + "sass-embedded-linux-musl-x64": "1.99.0", + "sass-embedded-linux-riscv64": "1.99.0", + "sass-embedded-linux-x64": "1.99.0", + "sass-embedded-unknown-all": "1.99.0", + "sass-embedded-win32-arm64": "1.99.0", + "sass-embedded-win32-x64": "1.99.0" + } + }, + "node_modules/sass-embedded-all-unknown": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.99.0.tgz", + "integrity": "sha512-qPIRG8Uhjo6/OKyAKixTnwMliTz+t9K6Duk0mx5z+K7n0Ts38NSJz2sjDnc7cA/8V9Lb3q09H38dZ1CLwD+ssw==", + "cpu": [ + "!arm", + "!arm64", + "!riscv64", + "!x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "sass": "1.99.0" + } + }, + "node_modules/sass-embedded-android-arm": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.99.0.tgz", + "integrity": "sha512-EHvJ0C7/VuP78Qr6f8gIUVUmCqIorEQpw2yp3cs3SMg02ZuumlhjXvkTcFBxHmFdFR23vTNk1WnhY6QSeV1nFQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-arm64": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.99.0.tgz", + "integrity": "sha512-fNHhdnP23yqqieCbAdym4N47AleSwjbNt6OYIYx4DdACGdtERjQB4iOX/TaKsW034MupfF7SjnAAK8w7Ptldtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-riscv64": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.99.0.tgz", + "integrity": "sha512-4zqDFRvgGDTL5vTHuIhRxUpXFoh0Cy7Gm5Ywk19ASd8Settmd14YdPRZPmMxfgS1GH292PofV1fq1ifiSEJWBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-x64": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.99.0.tgz", + "integrity": "sha512-Uk53k/dGYt04RjOL4gFjZ0Z9DH9DKh8IA8WsXUkNqsxerAygoy3zqRBS2zngfE9K2jiOM87q+1R1p87ory9oQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-arm64": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.99.0.tgz", + "integrity": "sha512-u61/7U3IGLqoO6gL+AHeiAtlTPFwJK1+964U8gp45ZN0hzh1yrARf5O1mivXv8NnNgJvbG2wWJbiNZP0lG/lTg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-x64": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.99.0.tgz", + "integrity": "sha512-j/kkk/NcXdIameLezSfXjgCiBkVcA+G60AXrX768/3g0miK1g7M9dj7xOhCb1i7/wQeiEI3rw2LLuO63xRIn4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.99.0.tgz", + "integrity": "sha512-d4IjJZrX2+AwB2YCy1JySwdptJECNP/WfAQLUl8txI3ka8/d3TUI155GtelnoZUkio211PwIeFvvAeZ9RXPQnw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm64": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.99.0.tgz", + "integrity": "sha512-btNcFpItcB56L40n8hDeL7sRSMLDXQ56nB5h2deddJx1n60rpKSElJmkaDGHtpkrY+CTtDRV0FZDjHeTJddYew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.99.0.tgz", + "integrity": "sha512-2gvHOupgIw3ytatXT4nFUow71LFbuOZPEwG+HUzcNQDH8ue4Ez8cr03vsv5MDv3lIjOKcXwDvWD980t18MwkoQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm64": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.99.0.tgz", + "integrity": "sha512-Hi2bt/IrM5P4FBKz6EcHAlniwfpoz9mnTdvSd58y+avA3SANM76upIkAdSayA8ZGwyL3gZokru1AKDPF9lJDNw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-riscv64": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.99.0.tgz", + "integrity": "sha512-mKqGvVaJ9rHMqyZsF0kikQe4NO0f4osb67+X6nLhBiVDKvyazQHJ3zJQreNefIE36yL2sjHIclSB//MprzaQDg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-x64": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.99.0.tgz", + "integrity": "sha512-huhgOMmOc30r7CH7qbRbT9LerSEGSnWuS4CYNOskr9BvNeQp4dIneFufNRGZ7hkOAxUM8DglxIZJN/cyAT95Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-riscv64": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.99.0.tgz", + "integrity": "sha512-mevFPIFAVhrH90THifxLfOntFmHtcEKOcdWnep2gJ0X4DVva4AiVIRlQe/7w9JFx5+gnDRE1oaJJkzuFUuYZsA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-x64": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.99.0.tgz", + "integrity": "sha512-9k7IkULqIZdCIVt4Mboryt6vN8Mjmm3EhI1P3mClU5y5i3wLK5ExC3cbVWk047KsID/fvB1RLslqghXJx5BoxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-unknown-all": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.99.0.tgz", + "integrity": "sha512-P7MxiUtL/XzGo3PX0CaB8lNNEFLQWKikPA8pbKytx9ZCLZSDkt2NJcdAbblB/sqMs4AV3EK2NadV8rI/diq3xg==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "!android", + "!darwin", + "!linux", + "!win32" + ], + "dependencies": { + "sass": "1.99.0" + } + }, + "node_modules/sass-embedded-win32-arm64": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.99.0.tgz", + "integrity": "sha512-8whpsW7S+uO8QApKfQuc36m3P9EISzbVZOgC79goob4qGy09u8Gz/rYvw8h1prJDSjltpHGhOzBE6LDz7WvzVw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-win32-x64": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.99.0.tgz", + "integrity": "sha512-ipuOv1R2K4MHeuCEAZGpuUbAgma4gb0sdacyrTjJtMOy/OY9UvWfVlwErdB09KIkp4fPDpQJDJfvYN6bC8jeNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/sync-child-process": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", + "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sync-message-port": "^1.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/sync-message-port": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.2.0.tgz", + "integrity": "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/unplugin-vue-components": { + "version": "32.0.0", + "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-32.0.0.tgz", + "integrity": "sha512-uLdccgS7mf3pv1bCCP20y/hm+u1eOjAmygVkh+Oa70MPkzgl1eQv1L0CwdHNM3gscO8/GDMGIET98Ja47CBbZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.2", + "obug": "^2.1.1", + "picomatch": "^4.0.3", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.2 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/unplugin-vue-components/node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/varint": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", + "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.2.6.tgz", + "integrity": "sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==", + "license": "MIT" + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.6.tgz", + "integrity": "sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.2.6" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/cms/package.json b/cms/package.json new file mode 100644 index 0000000..76486c6 --- /dev/null +++ b/cms/package.json @@ -0,0 +1,37 @@ +{ + "name": "cms", + "private": true, + "version": "0.0.0", + "type": "module", + "author": "nobswebdev", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "@tiptap/extension-link": "^3.22.5", + "@tiptap/extension-placeholder": "^3.22.5", + "@tiptap/starter-kit": "^3.22.5", + "@tiptap/vue-3": "^3.22.5", + "axios": "^1.15.1", + "dayjs": "^1.11.19", + "decimal.js": "^10.6.0", + "element-plus": "^2.13.6", + "pinia": "^3.0.4", + "pretty-bytes": "^7.1.0", + "vue": "^3.5.32", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@types/node": "^24.12.2", + "@vitejs/plugin-vue": "^6.0.5", + "@vue/tsconfig": "^0.9.1", + "sass-embedded": "^1.99.0", + "typescript": "~6.0.2", + "unplugin-vue-components": "^32.0.0", + "vite": "^8.0.4", + "vue-tsc": "^3.2.6" + } +} diff --git a/cms/public/favicon.svg b/cms/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/cms/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/cms/src/App.vue b/cms/src/App.vue new file mode 100644 index 0000000..e4a205a --- /dev/null +++ b/cms/src/App.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/cms/src/components.d.ts b/cms/src/components.d.ts new file mode 100644 index 0000000..349adca --- /dev/null +++ b/cms/src/components.d.ts @@ -0,0 +1,75 @@ +/* eslint-disable */ +// @ts-nocheck +// biome-ignore lint: disable +// oxlint-disable +// ------ +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 + +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + CmsListPagination: typeof import('./components/CmsListPagination.vue')['default'] + CreateOrEditCategoryModal: typeof import('./components/CreateOrEditCategoryModal.vue')['default'] + CreateOrEditDiscountCodeModal: typeof import('./components/CreateOrEditDiscountCodeModal.vue')['default'] + CreateProductVariantModal: typeof import('./components/CreateProductVariantModal.vue')['default'] + DiscountScopePicker: typeof import('./components/DiscountScopePicker.vue')['default'] + ElAlert: typeof import('element-plus/es')['ElAlert'] + ElBadge: typeof import('element-plus/es')['ElBadge'] + ElButton: typeof import('element-plus/es')['ElButton'] + ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup'] + ElCard: typeof import('element-plus/es')['ElCard'] + ElCol: typeof import('element-plus/es')['ElCol'] + ElContainer: typeof import('element-plus/es')['ElContainer'] + ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] + ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] + ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] + ElDialog: typeof import('element-plus/es')['ElDialog'] + ElEmpty: typeof import('element-plus/es')['ElEmpty'] + ElForm: typeof import('element-plus/es')['ElForm'] + ElFormItem: typeof import('element-plus/es')['ElFormItem'] + ElHeader: typeof import('element-plus/es')['ElHeader'] + ElIcon: typeof import('element-plus/es')['ElIcon'] + ElIconLoading: typeof import('@element-plus/icons-vue')['Loading'] + ElInput: typeof import('element-plus/es')['ElInput'] + ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] + ElMain: typeof import('element-plus/es')['ElMain'] + ElMenu: typeof import('element-plus/es')['ElMenu'] + ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] + ElOption: typeof import('element-plus/es')['ElOption'] + ElPagination: typeof import('element-plus/es')['ElPagination'] + ElRadio: typeof import('element-plus/es')['ElRadio'] + ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] + ElRow: typeof import('element-plus/es')['ElRow'] + ElSelect: typeof import('element-plus/es')['ElSelect'] + ElSwitch: typeof import('element-plus/es')['ElSwitch'] + ElTable: typeof import('element-plus/es')['ElTable'] + ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] + ElTabPane: typeof import('element-plus/es')['ElTabPane'] + ElTabs: typeof import('element-plus/es')['ElTabs'] + ElTag: typeof import('element-plus/es')['ElTag'] + ElText: typeof import('element-plus/es')['ElText'] + ElTooltip: typeof import('element-plus/es')['ElTooltip'] + ElUpload: typeof import('element-plus/es')['ElUpload'] + MoneroWallet: typeof import('./components/wallet/MoneroWallet.vue')['default'] + OrderCartPanel: typeof import('./components/OrderCartPanel.vue')['default'] + OrderChatPanel: typeof import('./components/OrderChatPanel.vue')['default'] + OrderLineAutoFulfillmentModal: typeof import('./components/OrderLineAutoFulfillmentModal.vue')['default'] + OrderManualShippingQuotePanel: typeof import('./components/OrderManualShippingQuotePanel.vue')['default'] + OrderMoneroPaymentPanel: typeof import('./components/OrderMoneroPaymentPanel.vue')['default'] + OrderPaymentPanel: typeof import('./components/OrderPaymentPanel.vue')['default'] + OrderSummaryPanel: typeof import('./components/OrderSummaryPanel.vue')['default'] + RichTextEditor: typeof import('./components/RichTextEditor.vue')['default'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + ThemeToggle: typeof import('./components/ThemeToggle.vue')['default'] + VariantDetailDetailsCard: typeof import('./components/variantDetail/VariantDetailDetailsCard.vue')['default'] + VariantDetailDigitalStockSection: typeof import('./components/variantDetail/VariantDetailDigitalStockSection.vue')['default'] + VariantDetailImagesCard: typeof import('./components/variantDetail/VariantDetailImagesCard.vue')['default'] + } + export interface GlobalDirectives { + vLoading: typeof import('element-plus/es')['ElLoadingDirective'] + } +} diff --git a/cms/src/components/CmsListPagination.vue b/cms/src/components/CmsListPagination.vue new file mode 100644 index 0000000..893a8f9 --- /dev/null +++ b/cms/src/components/CmsListPagination.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/cms/src/components/CreateOrEditCategoryModal.vue b/cms/src/components/CreateOrEditCategoryModal.vue new file mode 100644 index 0000000..ec20360 --- /dev/null +++ b/cms/src/components/CreateOrEditCategoryModal.vue @@ -0,0 +1,151 @@ + + + diff --git a/cms/src/components/CreateOrEditDiscountCodeModal.vue b/cms/src/components/CreateOrEditDiscountCodeModal.vue new file mode 100644 index 0000000..f10cb36 --- /dev/null +++ b/cms/src/components/CreateOrEditDiscountCodeModal.vue @@ -0,0 +1,378 @@ + + + + + diff --git a/cms/src/components/CreateProductVariantModal.vue b/cms/src/components/CreateProductVariantModal.vue new file mode 100644 index 0000000..709828d --- /dev/null +++ b/cms/src/components/CreateProductVariantModal.vue @@ -0,0 +1,154 @@ + + + diff --git a/cms/src/components/DiscountScopePicker.vue b/cms/src/components/DiscountScopePicker.vue new file mode 100644 index 0000000..bcdd8a4 --- /dev/null +++ b/cms/src/components/DiscountScopePicker.vue @@ -0,0 +1,243 @@ + + + + + diff --git a/cms/src/components/OrderCartPanel.vue b/cms/src/components/OrderCartPanel.vue new file mode 100644 index 0000000..d9ad223 --- /dev/null +++ b/cms/src/components/OrderCartPanel.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/cms/src/components/OrderChatPanel.vue b/cms/src/components/OrderChatPanel.vue new file mode 100644 index 0000000..fd39af5 --- /dev/null +++ b/cms/src/components/OrderChatPanel.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/cms/src/components/OrderLineAutoFulfillmentModal.vue b/cms/src/components/OrderLineAutoFulfillmentModal.vue new file mode 100644 index 0000000..d5470d0 --- /dev/null +++ b/cms/src/components/OrderLineAutoFulfillmentModal.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/cms/src/components/OrderManualShippingQuotePanel.vue b/cms/src/components/OrderManualShippingQuotePanel.vue new file mode 100644 index 0000000..91c037d --- /dev/null +++ b/cms/src/components/OrderManualShippingQuotePanel.vue @@ -0,0 +1,188 @@ + + + diff --git a/cms/src/components/OrderMoneroPaymentPanel.vue b/cms/src/components/OrderMoneroPaymentPanel.vue new file mode 100644 index 0000000..ab3241d --- /dev/null +++ b/cms/src/components/OrderMoneroPaymentPanel.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/cms/src/components/OrderPaymentPanel.vue b/cms/src/components/OrderPaymentPanel.vue new file mode 100644 index 0000000..1728530 --- /dev/null +++ b/cms/src/components/OrderPaymentPanel.vue @@ -0,0 +1,25 @@ + + + diff --git a/cms/src/components/OrderSummaryPanel.vue b/cms/src/components/OrderSummaryPanel.vue new file mode 100644 index 0000000..f27794e --- /dev/null +++ b/cms/src/components/OrderSummaryPanel.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/cms/src/components/RichTextEditor.vue b/cms/src/components/RichTextEditor.vue new file mode 100644 index 0000000..1a8dc8b --- /dev/null +++ b/cms/src/components/RichTextEditor.vue @@ -0,0 +1,243 @@ + + + + + diff --git a/cms/src/components/ThemeToggle.vue b/cms/src/components/ThemeToggle.vue new file mode 100644 index 0000000..1328eba --- /dev/null +++ b/cms/src/components/ThemeToggle.vue @@ -0,0 +1,24 @@ + + + diff --git a/cms/src/components/variantDetail/VariantDetailDetailsCard.vue b/cms/src/components/variantDetail/VariantDetailDetailsCard.vue new file mode 100644 index 0000000..a5a6cfe --- /dev/null +++ b/cms/src/components/variantDetail/VariantDetailDetailsCard.vue @@ -0,0 +1,132 @@ + + + diff --git a/cms/src/components/variantDetail/VariantDetailDigitalStockSection.vue b/cms/src/components/variantDetail/VariantDetailDigitalStockSection.vue new file mode 100644 index 0000000..88a6f5c --- /dev/null +++ b/cms/src/components/variantDetail/VariantDetailDigitalStockSection.vue @@ -0,0 +1,763 @@ + + + + + diff --git a/cms/src/components/variantDetail/VariantDetailImagesCard.vue b/cms/src/components/variantDetail/VariantDetailImagesCard.vue new file mode 100644 index 0000000..fbb66f2 --- /dev/null +++ b/cms/src/components/variantDetail/VariantDetailImagesCard.vue @@ -0,0 +1,345 @@ + + + + + diff --git a/cms/src/components/wallet/MoneroWallet.vue b/cms/src/components/wallet/MoneroWallet.vue new file mode 100644 index 0000000..96ccc45 --- /dev/null +++ b/cms/src/components/wallet/MoneroWallet.vue @@ -0,0 +1,312 @@ + + + diff --git a/cms/src/composables/usePolling.ts b/cms/src/composables/usePolling.ts new file mode 100644 index 0000000..829b206 --- /dev/null +++ b/cms/src/composables/usePolling.ts @@ -0,0 +1,63 @@ +import { onScopeDispose, watch } from 'vue'; +import type { UsePollingOptions } from '@/types/UsePollingOptions'; + +export const usePolling = ( + callback: () => void | Promise, + { intervalMs, enabled, immediate = false }: UsePollingOptions +): void => { + let timerId: ReturnType | null = null; + let inFlight = false; + + const tick = async (): Promise => { + if (inFlight || enabled?.value === false) { + return; + } + + inFlight = true; + + try { + await callback(); + } finally { + inFlight = false; + } + }; + + const start = (): void => { + if (timerId !== null) { + return; + } + + if (immediate) { + tick(); + } + + timerId = setInterval(() => tick(), intervalMs); + }; + + const stop = (): void => { + if (timerId === null) { + return; + } + + clearInterval(timerId); + timerId = null; + }; + + if (enabled) { + watch( + enabled, + isEnabled => { + if (isEnabled) { + start(); + } else { + stop(); + } + }, + { immediate: true } + ); + } else { + start(); + } + + onScopeDispose(stop); +}; diff --git a/cms/src/config/index.ts b/cms/src/config/index.ts new file mode 100644 index 0000000..12e7788 --- /dev/null +++ b/cms/src/config/index.ts @@ -0,0 +1,51 @@ +export function env(name: keyof ImportMetaEnv): string { + const value = import.meta.env[name]; + + return typeof value === 'string' ? value : ''; +} + +const apiBaseUrl = env('VITE_API_BASE_URL'); + +export const config = { + api: { + baseUrl: apiBaseUrl, + rootUrl: apiBaseUrl.split('/api')[0] + }, + + shopFiatCurrency: env('VITE_SHOP_FIAT_CURRENCY'), + + productThumb: { + accept: env('VITE_PRODUCT_THUMB_ALLOWED_MIMES'), + maxFileBytes: parseInt(env('VITE_PRODUCT_THUMB_MAX_FILE_BYTES'), 10) + }, + + shopLogo: { + accept: env('VITE_SHOP_LOGO_ALLOWED_MIMES'), + maxFileBytes: parseInt(env('VITE_SHOP_LOGO_MAX_FILE_BYTES'), 10) + }, + + shopFavicon: { + accept: env('VITE_SHOP_FAVICON_ALLOWED_MIMES'), + maxFileBytes: parseInt(env('VITE_SHOP_FAVICON_MAX_FILE_BYTES'), 10) + }, + + digitalStockAttachment: { + accept: env('VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES'), + maxFileBytes: parseInt(env('VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES'), 10) + }, + + validation: { + productTitleMaxLength: parseInt(env('VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH'), 10), + categoryNameMaxLength: parseInt(env('VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH'), 10), + discountCodeMaxLength: parseInt(env('VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH'), 10), + variantImagesMax: parseInt(env('VITE_VALIDATION_VARIANT_IMAGES_MAX'), 10), + digitalStockAttachmentsMax: parseInt(env('VITE_VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX'), 10), + shippingNoteMinLength: parseInt(env('VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH'), 10), + shippingNoteMaxLength: parseInt(env('VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH'), 10), + orderMessageMaxLength: parseInt(env('VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH'), 10) + }, + + orders: { + detailPollIntervalMs: parseInt(env('VITE_ORDERS_DETAIL_POLL_INTERVAL_MS'), 10) + } +}; diff --git a/cms/src/consts/routeNames.ts b/cms/src/consts/routeNames.ts new file mode 100644 index 0000000..128d560 --- /dev/null +++ b/cms/src/consts/routeNames.ts @@ -0,0 +1,13 @@ +export const ROUTE_NAMES = { + Login: 'CmsLogin', + ProductDetail: 'ProductDetail', + ProductVariantDetail: 'ProductVariantDetail', + Products: 'Products', + Categories: 'Categories', + DiscountCodes: 'DiscountCodes', + ShopSettings: 'ShopSettings', + Notifications: 'Notifications', + Wallet: 'Wallet', + Orders: 'Orders', + OrderDetail: 'OrderDetail' +} as const; diff --git a/cms/src/consts/untitledProductTitle.ts b/cms/src/consts/untitledProductTitle.ts new file mode 100644 index 0000000..059874d --- /dev/null +++ b/cms/src/consts/untitledProductTitle.ts @@ -0,0 +1 @@ +export const UNTITLED_PRODUCT_TITLE = '(untitled)'; diff --git a/cms/src/main.ts b/cms/src/main.ts new file mode 100644 index 0000000..fc7fe01 --- /dev/null +++ b/cms/src/main.ts @@ -0,0 +1,11 @@ +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import ElementPlus from 'element-plus'; +import 'element-plus/dist/index.css'; +import 'element-plus/theme-chalk/dark/css-vars.css'; +import './styles/utils.scss'; +import './styles/responsive.scss'; +import App from './App.vue'; +import router from './router'; + +createApp(App).use(createPinia()).use(ElementPlus).use(router).mount('#app'); diff --git a/cms/src/plugins/axios.ts b/cms/src/plugins/axios.ts new file mode 100644 index 0000000..70c9340 --- /dev/null +++ b/cms/src/plugins/axios.ts @@ -0,0 +1,39 @@ +import axios, { type AxiosError, HttpStatusCode } from 'axios'; +import { ElMessage } from 'element-plus'; +import router from '@/router'; +import { ROUTE_NAMES } from '@/consts/routeNames'; +import { useAuthStore } from '@/stores/auth'; +import { config } from '@/config'; + +export const api = axios.create({ + baseURL: config.api.baseUrl, + withCredentials: true +}); + +api.interceptors.response.use( + response => response, + (error: AxiosError) => { + const status = error.response?.status; + const url = error.config?.url ?? ''; + + if (status === HttpStatusCode.Unauthorized && !url.includes('/auth/login')) { + ElMessage.error('Your session ended. Please sign in again.'); + + const { clearSession } = useAuthStore(); + + clearSession(); + + router.push({ name: ROUTE_NAMES.Login }); + } + + if (status === HttpStatusCode.Forbidden) { + ElMessage.error('You do not have access to this resource.'); + } + + if (status === HttpStatusCode.TooManyRequests) { + ElMessage.error('Too many requests. Please slow down and try again.'); + } + + return Promise.reject(error); + } +); diff --git a/cms/src/plugins/dayjs.ts b/cms/src/plugins/dayjs.ts new file mode 100644 index 0000000..7ae2fb5 --- /dev/null +++ b/cms/src/plugins/dayjs.ts @@ -0,0 +1,6 @@ +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; + +dayjs.extend(relativeTime); + +export default dayjs; diff --git a/cms/src/router/index.ts b/cms/src/router/index.ts new file mode 100644 index 0000000..df42520 --- /dev/null +++ b/cms/src/router/index.ts @@ -0,0 +1,106 @@ +import { createRouter, createWebHistory } from 'vue-router'; +import { useAuthStore } from '@/stores/auth'; +import { ROUTE_NAMES } from '@/consts/routeNames'; + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: '/login', + name: ROUTE_NAMES.Login, + component: () => import('../views/CmsLoginView.vue'), + meta: { title: 'CMS - Sign in', hideNav: true } + }, + { + path: '/products/:id', + name: ROUTE_NAMES.ProductDetail, + component: () => import('../views/CmsProductDetailView.vue'), + meta: { requiresAuth: true, title: 'CMS - Product', activeMenu: '/products' } + }, + { + path: '/products/:productId/variants/:variantId', + name: ROUTE_NAMES.ProductVariantDetail, + component: () => import('../views/CmsProductVariantDetailView.vue'), + meta: { requiresAuth: true, title: 'CMS - Variant', activeMenu: '/products' } + }, + { + path: '/products', + name: ROUTE_NAMES.Products, + component: () => import('../views/CmsProductsView.vue'), + meta: { requiresAuth: true, title: 'CMS - Products' } + }, + { + path: '/categories', + name: ROUTE_NAMES.Categories, + component: () => import('../views/CmsCategoriesView.vue'), + meta: { requiresAuth: true, title: 'CMS - Categories' } + }, + { + path: '/discount-codes', + name: ROUTE_NAMES.DiscountCodes, + component: () => import('../views/CmsDiscountCodesView.vue'), + meta: { requiresAuth: true, title: 'CMS - Discount codes' } + }, + { + path: '/orders/:id', + name: ROUTE_NAMES.OrderDetail, + component: () => import('../views/CmsOrderDetailView.vue'), + meta: { requiresAuth: true, title: 'CMS - Order', activeMenu: '/orders' } + }, + { + path: '/orders', + name: ROUTE_NAMES.Orders, + component: () => import('../views/CmsOrdersView.vue'), + meta: { requiresAuth: true, title: 'CMS - Orders' } + }, + { + path: '/settings', + component: () => import('../views/CmsSettingsLayout.vue'), + meta: { requiresAuth: true, title: 'CMS - Settings', activeMenu: '/settings' }, + children: [ + { + path: '', + name: ROUTE_NAMES.ShopSettings, + component: () => import('../views/CmsShopSettingsView.vue'), + meta: { requiresAuth: true, title: 'CMS - Shop settings', activeMenu: '/settings' } + }, + { + path: 'notifications', + name: ROUTE_NAMES.Notifications, + component: () => import('../views/CmsNotificationsView.vue'), + meta: { requiresAuth: true, title: 'CMS - Notifications', activeMenu: '/settings' } + } + ] + }, + { + path: '/wallet', + name: ROUTE_NAMES.Wallet, + component: () => import('../views/CmsWalletView.vue'), + meta: { requiresAuth: true, title: 'CMS - Wallet' } + }, + { + path: '/:pathMatch(.*)*', + redirect: { name: ROUTE_NAMES.Login } + } + ] +}); + +router.beforeEach(to => { + const auth = useAuthStore(); + + if (to.meta.requiresAuth && !auth.isSignedIn) { + return { name: ROUTE_NAMES.Login }; + } + + return true; +}); + +router.afterEach(to => { + const title = to.meta.title; + + if (title && typeof title === 'string') { + document.title = title; + } +}); + +export default router; diff --git a/cms/src/stores/auth.ts b/cms/src/stores/auth.ts new file mode 100644 index 0000000..b7ba62f --- /dev/null +++ b/cms/src/stores/auth.ts @@ -0,0 +1,31 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import { api } from '@/plugins/axios'; + +const CMS_AUTH_STORAGE_KEY = 'cms-auth'; + +export const useAuthStore = defineStore('auth', () => { + const isSignedIn = ref(localStorage.getItem(CMS_AUTH_STORAGE_KEY) === '1'); + + const markSignedIn = () => { + localStorage.setItem(CMS_AUTH_STORAGE_KEY, '1'); + isSignedIn.value = true; + }; + + const clearSession = () => { + localStorage.removeItem(CMS_AUTH_STORAGE_KEY); + isSignedIn.value = false; + }; + + const login = async (password: string) => { + await api.post('/auth/login', { password }); + + markSignedIn(); + }; + + return { + isSignedIn, + login, + clearSession + }; +}); diff --git a/cms/src/stores/categories.ts b/cms/src/stores/categories.ts new file mode 100644 index 0000000..36705ec --- /dev/null +++ b/cms/src/stores/categories.ts @@ -0,0 +1,57 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import { api } from '@/plugins/axios'; +import type { CreateOrUpdateCategoryPayload } from '@/types/category/CreateOrUpdateCategoryPayload'; +import type { Category } from '@/types/product/Category'; + +export const useCategoriesStore = defineStore('categories', () => { + const categories = ref([]); + + const upsertCategory = (data: Category): void => { + const idx = categories.value.findIndex(c => c.id === data.id); + + if (idx !== -1) { + categories.value[idx] = data; + } else { + categories.value.push(data); + } + + categories.value.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)); + }; + + const fetchAll = async (): Promise => { + const { data } = await api.get('/categories'); + + categories.value = data; + }; + + const createCategory = async (payload: CreateOrUpdateCategoryPayload): Promise => { + const { data } = await api.post('/categories', payload); + + upsertCategory(data); + + return data; + }; + + const updateCategory = async (id: string, payload: CreateOrUpdateCategoryPayload): Promise => { + const { data } = await api.patch(`/categories/${id}`, payload); + + upsertCategory(data); + + return data; + }; + + const removeCategory = async (id: string): Promise => { + await api.delete(`/categories/${id}`); + + categories.value = categories.value.filter(c => c.id !== id); + }; + + return { + categories, + fetchAll, + createCategory, + updateCategory, + removeCategory + }; +}); diff --git a/cms/src/stores/colorScheme.ts b/cms/src/stores/colorScheme.ts new file mode 100644 index 0000000..f409e70 --- /dev/null +++ b/cms/src/stores/colorScheme.ts @@ -0,0 +1,46 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; + +const STORAGE_KEY = 'cms-color-mode'; + +const resolveInitialDark = (): boolean => { + try { + const saved = localStorage.getItem(STORAGE_KEY); + + if (saved === 'dark') { + return true; + } + + if (saved === 'light') { + return false; + } + + return window.matchMedia('(prefers-color-scheme: dark)').matches; + } catch { + return false; + } +}; + +export const useColorSchemeStore = defineStore('colorScheme', () => { + const isDark = ref(false); + + const initColorScheme = (): void => { + isDark.value = resolveInitialDark(); + + document.documentElement.classList.toggle('dark', isDark.value); + }; + + const toggleDarkMode = (): void => { + isDark.value = !isDark.value; + + document.documentElement.classList.toggle('dark', isDark.value); + + localStorage.setItem(STORAGE_KEY, isDark.value ? 'dark' : 'light'); + }; + + return { + isDark, + initColorScheme, + toggleDarkMode + }; +}); diff --git a/cms/src/stores/digitalStock.ts b/cms/src/stores/digitalStock.ts new file mode 100644 index 0000000..4c6288b --- /dev/null +++ b/cms/src/stores/digitalStock.ts @@ -0,0 +1,148 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import { api } from '@/plugins/axios'; +import type { PaginatedResponse } from '@/types/PaginatedResponse'; +import type { DigitalStockItem } from '@/types/product/DigitalStockItem'; +import type { DigitalStockListQuery } from '@/types/product/DigitalStockListQuery'; +import { useProductsStore } from './products'; + +export const useDigitalStockStore = defineStore('digitalStock', () => { + const items = ref([]); + + const resetList = (): void => { + items.value = []; + }; + + const upsertItemInList = (item: DigitalStockItem): void => { + const idx = items.value.findIndex(row => row.id === item.id); + + if (idx === -1) { + items.value.push(item); + } else { + items.value[idx] = item; + } + }; + + const fetchList = async ( + productId: string, + variantId: string, + { page, limit, hideSold }: DigitalStockListQuery + ): Promise> => { + const { data } = await api.get>( + `/products/${productId}/variants/${variantId}/digital-stock-items`, + { params: { page, limit, hideSold: hideSold ? 1 : 0 } } + ); + + items.value = data.items; + + return data; + }; + + const addItem = async (productId: string, variantId: string, content: string): Promise => { + const { data } = await api.post( + `/products/${productId}/variants/${variantId}/digital-stock-items`, + { content } + ); + + const { adjustVariantStockAvailable } = useProductsStore(); + + adjustVariantStockAvailable(productId, variantId, 1); + + return data; + }; + + const updateItem = async ( + productId: string, + variantId: string, + itemId: string, + content: string + ): Promise => { + const { data } = await api.patch( + `/products/${productId}/variants/${variantId}/digital-stock-items/${itemId}`, + { content } + ); + + upsertItemInList(data); + + return data; + }; + + const removeItem = async (productId: string, variantId: string, itemId: string, isSold: boolean): Promise => { + await api.delete(`/products/${productId}/variants/${variantId}/digital-stock-items/${itemId}`); + + if (!isSold) { + const { adjustVariantStockAvailable } = useProductsStore(); + + adjustVariantStockAvailable(productId, variantId, -1); + } + }; + + const uploadAttachment = async ( + productId: string, + variantId: string, + itemId: string, + file: File + ): Promise => { + const formData = new FormData(); + + formData.append('file', file); + + const { data } = await api.post( + `/products/${productId}/variants/${variantId}/digital-stock-items/${itemId}/attachments`, + formData + ); + + upsertItemInList(data); + + return data; + }; + + const removeAttachment = async ( + productId: string, + variantId: string, + itemId: string, + attachmentId: string + ): Promise => { + const { data } = await api.delete( + `/products/${productId}/variants/${variantId}/digital-stock-items/${itemId}/attachments/${attachmentId}` + ); + + upsertItemInList(data); + + return data; + }; + + const downloadAttachment = async ( + productId: string, + variantId: string, + itemId: string, + attachmentId: string, + filename: string + ): Promise => { + const { data } = await api.get( + `/products/${productId}/variants/${variantId}/digital-stock-items/${itemId}/attachments/${attachmentId}/download`, + { responseType: 'blob' } + ); + + const url = URL.createObjectURL(data); + const anchor = document.createElement('a'); + + anchor.href = url; + anchor.download = filename; + anchor.click(); + + URL.revokeObjectURL(url); + }; + + return { + items, + resetList, + fetchList, + addItem, + updateItem, + removeItem, + uploadAttachment, + removeAttachment, + downloadAttachment + }; +}); diff --git a/cms/src/stores/discountCodes.ts b/cms/src/stores/discountCodes.ts new file mode 100644 index 0000000..37d5fd8 --- /dev/null +++ b/cms/src/stores/discountCodes.ts @@ -0,0 +1,67 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import { api } from '@/plugins/axios'; +import type { CreateOrUpdateDiscountCodePayload } from '@/types/discountCode/CreateOrUpdateDiscountCodePayload'; +import type { DiscountCode } from '@/types/discountCode/DiscountCode'; + +export const useDiscountCodesStore = defineStore('discountCodes', () => { + const discountCodes = ref([]); + + const upsertDiscountCode = (data: DiscountCode): void => { + const idx = discountCodes.value.findIndex(c => c.id === data.id); + + if (idx !== -1) { + discountCodes.value[idx] = data; + } else { + discountCodes.value.unshift(data); + } + }; + + const fetchAll = async (): Promise => { + const { data } = await api.get('/discount-codes'); + + discountCodes.value = data; + }; + + const fetchDiscountCodeById = async (id: string): Promise => { + const { data } = await api.get(`/discount-codes/${id}`); + + upsertDiscountCode(data); + + return data; + }; + + const createDiscountCode = async (payload: CreateOrUpdateDiscountCodePayload): Promise => { + const { data } = await api.post('/discount-codes', payload); + + upsertDiscountCode(data); + + return data; + }; + + const updateDiscountCode = async ( + id: string, + payload: CreateOrUpdateDiscountCodePayload + ): Promise => { + const { data } = await api.patch(`/discount-codes/${id}`, payload); + + upsertDiscountCode(data); + + return data; + }; + + const removeDiscountCode = async (id: string): Promise => { + await api.delete(`/discount-codes/${id}`); + + discountCodes.value = discountCodes.value.filter(c => c.id !== id); + }; + + return { + discountCodes, + fetchAll, + fetchDiscountCodeById, + createDiscountCode, + updateDiscountCode, + removeDiscountCode + }; +}); diff --git a/cms/src/stores/moneroWallet.ts b/cms/src/stores/moneroWallet.ts new file mode 100644 index 0000000..052aea8 --- /dev/null +++ b/cms/src/stores/moneroWallet.ts @@ -0,0 +1,39 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import { api } from '@/plugins/axios'; +import type { MoneroWalletRevealSeedPayload } from '@/types/moneroWallet/MoneroWalletRevealSeedPayload'; +import type { MoneroWalletRevealSeedResult } from '@/types/moneroWallet/MoneroWalletRevealSeedResult'; +import type { MoneroWalletStatus } from '@/types/moneroWallet/MoneroWalletStatus'; +import type { MoneroWalletWithdrawPayload } from '@/types/moneroWallet/MoneroWalletWithdrawPayload'; +import type { MoneroWalletWithdrawResult } from '@/types/moneroWallet/MoneroWalletWithdrawResult'; + +export const useMoneroWalletStore = defineStore('moneroWallet', () => { + const status = ref(null); + + const fetchStatus = async (): Promise => { + const { data } = await api.get('/monero-wallet'); + + status.value = data; + + return data; + }; + + const withdrawAll = async (payload: MoneroWalletWithdrawPayload): Promise => { + const { data } = await api.post('/monero-wallet/withdraw', payload); + + return data; + }; + + const revealSeed = async (payload: MoneroWalletRevealSeedPayload): Promise => { + const { data } = await api.post('/monero-wallet/reveal-seed', payload); + + return data; + }; + + return { + status, + fetchStatus, + withdrawAll, + revealSeed + }; +}); diff --git a/cms/src/stores/orders.ts b/cms/src/stores/orders.ts new file mode 100644 index 0000000..7fd84a7 --- /dev/null +++ b/cms/src/stores/orders.ts @@ -0,0 +1,104 @@ +import { defineStore } from 'pinia'; +import { computed, ref } from 'vue'; +import { useRoute } from 'vue-router'; +import { ROUTE_NAMES } from '@/consts/routeNames'; +import { api } from '@/plugins/axios'; +import type { PaginatedResponse } from '@/types/PaginatedResponse'; +import type { OrderExtended } from '@/types/order/OrderExtended'; +import type { OrderListItem } from '@/types/order/OrderListItem'; +import type { OrderMessage } from '@/types/order/OrderMessage'; +import type { SetDeliveryCostPayload } from '@/types/order/SetDeliveryCostPayload'; + +export const useOrdersStore = defineStore('orders', () => { + const route = useRoute(); + + const orderList = ref([]); + const currentOrder = ref(null); + + const currentOrderId = computed(() => { + if (route.name !== ROUTE_NAMES.OrderDetail) { + return null; + } + + const id = route.params.id; + + return typeof id === 'string' && id ? id : null; + }); + + const fetchList = async ({ + page, + limit + }: { + page: number; + limit: number; + }): Promise> => { + const { data } = await api.get>('/orders', { + params: { page, limit } + }); + + orderList.value = data.items; + + return data; + }; + + const fetchById = async (id: string): Promise => { + const { data } = await api.get(`/orders/${id}`); + + currentOrder.value = data; + + return data; + }; + + const markChatRead = async (id: string): Promise => { + await api.post(`/orders/${id}/messages/mark-read`); + }; + + const sendMessage = async (id: string, body: string): Promise => { + const { data } = await api.post(`/orders/${id}/messages`, { body }); + + if (currentOrder.value?.id === id) { + currentOrder.value = { ...currentOrder.value, messages: data }; + } + + return data; + }; + + const deleteMessage = async (orderId: string, messageId: string): Promise => { + const { data } = await api.delete(`/orders/${orderId}/messages/${messageId}`); + + if (currentOrder.value?.id === orderId) { + currentOrder.value = { ...currentOrder.value, messages: data }; + } + + return data; + }; + + const setDeliveryCost = async (id: string, payload: SetDeliveryCostPayload): Promise => { + const { data } = await api.post(`/orders/${id}/delivery-cost`, payload); + + currentOrder.value = data; + + return data; + }; + + const fulfillManualLine = async (orderId: string, lineId: string): Promise => { + const { data } = await api.post(`/orders/${orderId}/lines/${lineId}/fulfill`); + + currentOrder.value = data; + + return data; + }; + + return { + orderList, + currentOrder, + currentOrderId, + fetchList, + fetchById, + markChatRead, + sendMessage, + deleteMessage, + setDeliveryCost, + fulfillManualLine + }; +}); diff --git a/cms/src/stores/products.ts b/cms/src/stores/products.ts new file mode 100644 index 0000000..5722c50 --- /dev/null +++ b/cms/src/stores/products.ts @@ -0,0 +1,335 @@ +import { defineStore } from 'pinia'; +import { computed, ref } from 'vue'; +import { useRoute } from 'vue-router'; +import type { FormRules } from 'element-plus'; +import { config } from '@/config'; +import { ROUTE_NAMES } from '@/consts/routeNames'; +import { api } from '@/plugins/axios'; +import type { PaginatedResponse } from '@/types/PaginatedResponse'; +import type { ProductWithVariantsExtended } from '@/types/product/ProductWithVariantsExtended'; +import { DeliveryMode } from '@/types/product/DeliveryMode'; +import type { UpdateProductPayload } from '@/types/product/UpdateProductPayload'; +import type { ProductVariantPayload } from '@/types/product/ProductVariantPayload'; +import type { ProductVariant } from '@/types/product/ProductVariant'; +import type { ProductVariantExtended } from '@/types/product/ProductVariantExtended'; +import { compareProductVariants } from '@/utils/product/compareProductVariants'; +import { formatDeliveryMode } from '@/utils/product/formatDeliveryMode'; +import { getProductTitle } from '@/utils/product/getProductTitle'; + +const { + validation: { productTitleMaxLength: validationProductTitleMaxLength } +} = config; + +export const useProductsStore = defineStore('products', () => { + const route = useRoute(); + const products = ref([]); + + const currentProductId = computed(() => { + if (route.name === ROUTE_NAMES.ProductDetail) { + const id = route.params.id; + + return typeof id === 'string' && id ? id : null; + } + + if (route.name === ROUTE_NAMES.ProductVariantDetail) { + const id = route.params.productId; + + return typeof id === 'string' && id ? id : null; + } + + return null; + }); + + const currentVariantId = computed(() => { + if (route.name !== ROUTE_NAMES.ProductVariantDetail) { + return null; + } + + const id = route.params.variantId; + + return typeof id === 'string' && id ? id : null; + }); + + const currentProduct = computed(() => { + if (!currentProductId.value) { + return null; + } + + return products.value.find(product => product.id === currentProductId.value) ?? null; + }); + + const currentVariant = computed(() => { + if (!currentVariantId.value) { + return null; + } + + return currentProduct.value?.variants.find(variant => variant.id === currentVariantId.value) ?? null; + }); + + const currentDeliveryModeLabel = computed(() => + currentProduct.value ? formatDeliveryMode(currentProduct.value.deliveryMode) : null + ); + + const currentProductDisplayTitle = computed(() => + currentProduct.value ? getProductTitle(currentProduct.value.title) : null + ); + + const currentProductIsManual = computed( + () => currentProduct.value?.deliveryMode === DeliveryMode.Manual + ); + + const currentProductIsAuto = computed( + () => currentProduct.value?.deliveryMode === DeliveryMode.Auto + ); + + const canDeleteCurrentVariant = computed(() => (currentProduct.value?.variants.length ?? 0) > 1); + + const createOrEditCurrentProductVariantFormRules = computed(() => ({ + title: [ + { required: true, message: 'Required', trigger: 'blur' }, + { + max: validationProductTitleMaxLength, + message: `At most ${validationProductTitleMaxLength} characters`, + trigger: 'blur' + } + ], + price: [{ required: true, message: 'Required', trigger: 'change' }], + ...(currentProductIsManual.value + ? { + stockQuantity: [ + { required: true, message: 'Stock quantity is required', trigger: 'change' }, + { type: 'integer', message: 'Stock quantity must be a whole number', trigger: 'change' } + ] + } + : {}), + sortOrder: [ + { required: true, message: 'Sort order is required', trigger: 'change' }, + { type: 'integer', message: 'Sort order must be a whole number', trigger: 'change' } + ] + })); + + const upsertProduct = (data: ProductWithVariantsExtended): void => { + const idx = products.value.findIndex(r => r.id === data.id); + + if (idx !== -1) { + products.value[idx] = data; + } else { + products.value.unshift(data); + } + }; + + const upsertProductVariant = (productId: string, variant: ProductVariantExtended): void => { + const product = products.value.find(p => p.id === productId); + + if (!product) { + return; + } + + const idx = product.variants.findIndex(v => v.id === variant.id); + + if (idx === -1) { + product.variants.push(variant); + } else { + product.variants[idx] = variant; + } + + product.variants.sort(compareProductVariants); + }; + + const adjustVariantStockAvailable = (productId: string, variantId: string, delta: number): void => { + const product = products.value.find(p => p.id === productId); + const variant = product?.variants.find(v => v.id === variantId); + + if (variant) { + variant.stockAvailable = Math.max(0, variant.stockAvailable + delta); + } + }; + + const fetchProducts = async ({ + page, + limit, + search = '' + }: { + page: number; + limit: number; + search?: string; + }): Promise> => { + const { data } = await api.get>('/products', { + params: { page, limit, search } + }); + + return data; + }; + + const fetchProductVariants = async ({ + search = '', + page = 1, + limit = 20 + }: { + search?: string; + page?: number; + limit?: number; + } = {}): Promise> => { + const { data } = await api.get>('/products/variants', { + params: { search, page, limit } + }); + + return data; + }; + + const fetchProductById = async (id: string): Promise => { + const { data } = await api.get(`/products/${id}`); + + upsertProduct(data); + + return data; + }; + + const createDraftProduct = async (deliveryMode: DeliveryMode): Promise => { + const { data } = await api.post('/products', { deliveryMode }); + + upsertProduct(data); + + return data; + }; + + const updateProduct = async (id: string, payload: UpdateProductPayload): Promise => { + const { data } = await api.patch(`/products/${id}`, payload); + + upsertProduct(data); + + return data; + }; + + const createProductVariant = async ( + productId: string, + payload: ProductVariantPayload + ): Promise => { + const { data } = await api.post(`/products/${productId}/variants`, payload); + + upsertProductVariant(productId, data); + + return data; + }; + + const updateProductVariant = async ( + productId: string, + variantId: string, + payload: ProductVariantPayload + ): Promise => { + const { data } = await api.patch( + `/products/${productId}/variants/${variantId}`, + payload + ); + + upsertProductVariant(productId, data); + + return data; + }; + + const deleteProductVariant = async (productId: string, variantId: string): Promise => { + await api.delete(`/products/${productId}/variants/${variantId}`); + + const product = products.value.find(p => p.id === productId); + + if (product) { + product.variants = product.variants.filter(v => v.id !== variantId); + } + }; + + const uploadVariantImage = async ( + productId: string, + variantId: string, + file: File + ): Promise => { + const formData = new FormData(); + + formData.append('file', file); + + const { data } = await api.post( + `/products/${productId}/variants/${variantId}/images`, + formData + ); + + upsertProductVariant(productId, data); + + return data; + }; + + const removeVariantImage = async ( + productId: string, + variantId: string, + imageId: string + ): Promise => { + const { data } = await api.delete( + `/products/${productId}/variants/${variantId}/images/${imageId}` + ); + + upsertProductVariant(productId, data); + + return data; + }; + + const setVariantImageThumbnail = async ( + productId: string, + variantId: string, + imageId: string + ): Promise => { + const { data } = await api.patch( + `/products/${productId}/variants/${variantId}/images/${imageId}/set-thumbnail` + ); + + upsertProductVariant(productId, data); + + return data; + }; + + const reorderVariantImages = async ( + productId: string, + variantId: string, + imageIds: string[] + ): Promise => { + const { data } = await api.patch( + `/products/${productId}/variants/${variantId}/images/reorder`, + { imageIds } + ); + + upsertProductVariant(productId, data); + + return data; + }; + + const deleteProduct = async (id: string): Promise => { + await api.delete(`/products/${id}`); + + products.value = products.value.filter(p => p.id !== id); + }; + + return { + products, + currentProduct, + currentVariant, + currentProductId, + currentVariantId, + currentDeliveryModeLabel, + currentProductDisplayTitle, + currentProductIsManual, + currentProductIsAuto, + canDeleteCurrentVariant, + createOrEditCurrentProductVariantFormRules, + fetchProducts, + fetchProductVariants, + fetchProductById, + adjustVariantStockAvailable, + createDraftProduct, + updateProduct, + createProductVariant, + updateProductVariant, + deleteProductVariant, + uploadVariantImage, + removeVariantImage, + setVariantImageThumbnail, + reorderVariantImages, + deleteProduct + }; +}); diff --git a/cms/src/stores/shopSettings.ts b/cms/src/stores/shopSettings.ts new file mode 100644 index 0000000..ccc9629 --- /dev/null +++ b/cms/src/stores/shopSettings.ts @@ -0,0 +1,87 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import { api } from '@/plugins/axios'; +import type { ConnectSimplexNotificationsPayload } from '@/types/shopSettings/ConnectSimplexNotificationsPayload'; +import type { ShopSettings } from '@/types/shopSettings/ShopSettings'; +import type { UpdateNotificationsPayload } from '@/types/shopSettings/UpdateNotificationsPayload'; +import type { UpdateShippingNotePayload } from '@/types/shopSettings/UpdateShippingNotePayload'; +import type { UpdateSimplexLinkPayload } from '@/types/shopSettings/UpdateSimplexLinkPayload'; + +export const useShopSettingsStore = defineStore('shopSettings', () => { + const settings = ref(null); + + const fetchShopSettings = async (): Promise => { + const { data } = await api.get('/shop-settings'); + + settings.value = data; + + return data; + }; + + const updateSimplexLink = async (payload: UpdateSimplexLinkPayload): Promise => { + const { data } = await api.patch('/shop-settings/simplex-link', payload); + + settings.value = data; + + return data; + }; + + const updateShippingNote = async (payload: UpdateShippingNotePayload): Promise => { + const { data } = await api.patch('/shop-settings/shipping-note', payload); + + settings.value = data; + + return data; + }; + + const updateNotifications = async (payload: UpdateNotificationsPayload): Promise => { + const { data } = await api.patch('/shop-settings/notifications', payload); + + settings.value = data; + + return data; + }; + + const connectSimplexNotifications = async (payload: ConnectSimplexNotificationsPayload): Promise => { + const { data } = await api.post('/shop-settings/simplex-connect', payload); + + settings.value = data; + + return data; + }; + + const uploadShopLogo = async (file: File): Promise => { + const formData = new FormData(); + + formData.append('file', file); + + const { data } = await api.post('/shop-settings/logo', formData); + + settings.value = data; + + return data; + }; + + const uploadShopFavicon = async (file: File): Promise => { + const formData = new FormData(); + + formData.append('file', file); + + const { data } = await api.post('/shop-settings/favicon', formData); + + settings.value = data; + + return data; + }; + + return { + settings, + fetchShopSettings, + updateSimplexLink, + updateShippingNote, + updateNotifications, + connectSimplexNotifications, + uploadShopLogo, + uploadShopFavicon + }; +}); diff --git a/cms/src/styles/_breakpoints.scss b/cms/src/styles/_breakpoints.scss new file mode 100644 index 0000000..390470e --- /dev/null +++ b/cms/src/styles/_breakpoints.scss @@ -0,0 +1,2 @@ +$cms-bp-tablet: 767px; +$cms-bp-phone: 480px; diff --git a/cms/src/styles/responsive.scss b/cms/src/styles/responsive.scss new file mode 100644 index 0000000..709210c --- /dev/null +++ b/cms/src/styles/responsive.scss @@ -0,0 +1,124 @@ +@use './breakpoints' as *; + +.cms-table-scroll { + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.cms-page-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.cms-gate { + padding: 24px; +} + +.el-select__popper { + max-width: min(560px, calc(100vw - 32px)); +} + +.el-select-dropdown__item { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: $cms-bp-tablet) { + html, + body, + #app { + overflow-x: hidden; + } + + .el-header { + --el-header-padding: 0 10px; + } + + .el-main, + .el-main.main-wrapper { + --el-main-padding: 12px 10px; + padding: 12px 10px !important; + } + + .cms-page-header { + flex-wrap: wrap; + gap: 12px; + } + + .el-dialog { + width: calc(100vw - 32px) !important; + max-width: 100%; + } + + .order-detail-row { + margin-left: 0 !important; + margin-right: 0 !important; + row-gap: 24px; + + > .el-col { + padding-left: 0 !important; + padding-right: 0 !important; + } + } + + .cms-gate { + padding: 12px 10px; + } +} + +@media (max-width: $cms-bp-phone) { + .el-header { + --el-header-padding: 0 5px; + } + + .cms-header.el-header { + --el-header-padding: 12px 5px 0; + } + + .cms-header__bar { + flex-wrap: wrap; + row-gap: 8px; + } + + .cms-header__bar .logo { + flex-basis: 100%; + width: 100%; + } + + .el-main, + .el-main.main-wrapper { + --el-main-padding: 12px 5px; + padding: 12px 5px !important; + } + + .el-dialog { + width: calc(100vw - 10px) !important; + } + + .el-picker__popper { + max-width: calc(100vw - 10px); + } + + .el-date-picker { + width: min(322px, calc(100vw - 10px)) !important; + } + + .el-date-picker .el-picker-panel__content { + width: min(292px, calc(100vw - 30px)) !important; + } + + .el-time-panel { + width: min(180px, calc(100vw - 10px)) !important; + } + + .el-select__popper { + max-width: calc(100vw - 10px); + } + + .cms-gate { + padding: 12px 5px; + } +} diff --git a/cms/src/styles/utils.scss b/cms/src/styles/utils.scss new file mode 100644 index 0000000..7e05718 --- /dev/null +++ b/cms/src/styles/utils.scss @@ -0,0 +1,158 @@ +$space-scale: 0, 2, 4, 6, 8, 10, 12, 16, 20, 24, 32, 40, 48, 64; + +@each $s in $space-scale { + .m-#{$s} { + margin: #{$s}px; + } + .mt-#{$s} { + margin-top: #{$s}px; + } + .mr-#{$s} { + margin-right: #{$s}px; + } + .mb-#{$s} { + margin-bottom: #{$s}px; + } + .ml-#{$s} { + margin-left: #{$s}px; + } + .mx-#{$s} { + margin-left: #{$s}px; + margin-right: #{$s}px; + } + .my-#{$s} { + margin-top: #{$s}px; + margin-bottom: #{$s}px; + } + + .p-#{$s} { + padding: #{$s}px; + } + .pt-#{$s} { + padding-top: #{$s}px; + } + .pr-#{$s} { + padding-right: #{$s}px; + } + .pb-#{$s} { + padding-bottom: #{$s}px; + } + .pl-#{$s} { + padding-left: #{$s}px; + } + .px-#{$s} { + padding-left: #{$s}px; + padding-right: #{$s}px; + } + .py-#{$s} { + padding-top: #{$s}px; + padding-bottom: #{$s}px; + } + + .gap-#{$s} { + gap: #{$s}px; + } +} + +.w-full { + width: 100%; +} + +.box-border { + box-sizing: border-box; +} + +.flex { + display: flex; +} + +.flex-1 { + flex: 1 1 0%; +} + +.flex-shrink-0 { + flex-shrink: 0; +} + +.flex-col { + flex-direction: column; +} + +.min-h-0 { + min-height: 0; +} + +.min-w-0 { + min-width: 0; +} + +.text-ellipsis { + display: block; + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.flex-ellipsis { + flex: 1 1 0%; + min-width: 0; + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.text-break { + display: block; + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; +} + +.items-center { + align-items: center; +} + +.items-start { + align-items: flex-start; +} + +.self-start { + align-self: flex-start; +} + +.justify-between { + justify-content: space-between; +} + +.clickable-table .el-table__body tr { + cursor: pointer; +} + +.detail-loading-host { + min-height: min(50vh, 440px); +} + +.mono { + font-family: monospace; + + input, + textarea { + font-family: inherit; + } +} + +.secondary-text { + color: var(--el-text-color-secondary); + font-size: var(--el-font-size-small); +} + +.descriptions-row-labels { + .el-descriptions__label { + font-weight: bold; + } +} diff --git a/cms/src/types/BuildUploadHintOptions.ts b/cms/src/types/BuildUploadHintOptions.ts new file mode 100644 index 0000000..85c5d6a --- /dev/null +++ b/cms/src/types/BuildUploadHintOptions.ts @@ -0,0 +1,7 @@ +export type BuildUploadHintOptions = { + allowedMimesCsv: string; + maxFileBytes: number; + maxFiles?: number; + maxFilesLabel?: string; + encryptedAtRest?: boolean; +}; diff --git a/cms/src/types/PaginatedResponse.ts b/cms/src/types/PaginatedResponse.ts new file mode 100644 index 0000000..4c75076 --- /dev/null +++ b/cms/src/types/PaginatedResponse.ts @@ -0,0 +1,6 @@ +export type PaginatedResponse = { + items: T[]; + total: number; + page: number; + limit: number; +}; diff --git a/cms/src/types/UploadValidationOptions.ts b/cms/src/types/UploadValidationOptions.ts new file mode 100644 index 0000000..5cf1537 --- /dev/null +++ b/cms/src/types/UploadValidationOptions.ts @@ -0,0 +1,4 @@ +export type UploadValidationOptions = { + allowedMimesCsv: string; + maxFileBytes: number; +}; diff --git a/cms/src/types/UsePollingOptions.ts b/cms/src/types/UsePollingOptions.ts new file mode 100644 index 0000000..1ccbfcc --- /dev/null +++ b/cms/src/types/UsePollingOptions.ts @@ -0,0 +1,7 @@ +import type { Ref } from 'vue'; + +export type UsePollingOptions = { + intervalMs: number; + enabled?: Ref; + immediate?: boolean; +}; diff --git a/cms/src/types/category/CreateOrUpdateCategoryPayload.ts b/cms/src/types/category/CreateOrUpdateCategoryPayload.ts new file mode 100644 index 0000000..8683449 --- /dev/null +++ b/cms/src/types/category/CreateOrUpdateCategoryPayload.ts @@ -0,0 +1,4 @@ +export type CreateOrUpdateCategoryPayload = { + name: string; + sortOrder: number; +}; diff --git a/cms/src/types/discountCode/CreateOrUpdateDiscountCodePayload.ts b/cms/src/types/discountCode/CreateOrUpdateDiscountCodePayload.ts new file mode 100644 index 0000000..239448a --- /dev/null +++ b/cms/src/types/discountCode/CreateOrUpdateDiscountCodePayload.ts @@ -0,0 +1,16 @@ +import type { DiscountType } from './DiscountType'; + +export type CreateOrUpdateDiscountCodePayload = { + code: string; + type: DiscountType; + value: number; + isActive: boolean; + validFrom: string | Date | null; + validUntil: string | Date | null; + maxRedemptions: number | null; + minOrderAmount: number | null; + isExclusive: boolean; + productIds: string[]; + categoryIds: string[]; + variantIds: string[]; +}; diff --git a/cms/src/types/discountCode/DiscountCode.ts b/cms/src/types/discountCode/DiscountCode.ts new file mode 100644 index 0000000..0f5eef5 --- /dev/null +++ b/cms/src/types/discountCode/DiscountCode.ts @@ -0,0 +1,23 @@ +import type { Category } from '@/types/product/Category'; +import type { Product } from '@/types/product/Product'; +import type { ProductVariant } from '@/types/product/ProductVariant'; +import type { DiscountType } from './DiscountType'; + +export type DiscountCode = { + id: string; + code: string; + type: DiscountType; + value: number; + isActive: boolean; + validFrom: string | null; + validUntil: string | null; + maxRedemptions: number | null; + redemptionCount: number; + minOrderAmount: number | null; + isExclusive: boolean; + products?: Product[]; + categories?: Category[]; + variants?: ProductVariant[]; + createdAt: string; + updatedAt: string; +}; diff --git a/cms/src/types/discountCode/DiscountScope.ts b/cms/src/types/discountCode/DiscountScope.ts new file mode 100644 index 0000000..dfad547 --- /dev/null +++ b/cms/src/types/discountCode/DiscountScope.ts @@ -0,0 +1,6 @@ +export type DiscountScope = { + applyToAll: boolean; + categoryIds: string[]; + productIds: string[]; + variantIds: string[]; +}; diff --git a/cms/src/types/discountCode/DiscountType.ts b/cms/src/types/discountCode/DiscountType.ts new file mode 100644 index 0000000..111f2f7 --- /dev/null +++ b/cms/src/types/discountCode/DiscountType.ts @@ -0,0 +1,4 @@ +export enum DiscountType { + Percent = 'percent', + Fixed = 'fixed' +} diff --git a/cms/src/types/moneroWallet/MoneroNetwork.ts b/cms/src/types/moneroWallet/MoneroNetwork.ts new file mode 100644 index 0000000..a687bb3 --- /dev/null +++ b/cms/src/types/moneroWallet/MoneroNetwork.ts @@ -0,0 +1 @@ +export type MoneroNetwork = 'mainnet' | 'stagenet'; diff --git a/cms/src/types/moneroWallet/MoneroWalletRevealSeedPayload.ts b/cms/src/types/moneroWallet/MoneroWalletRevealSeedPayload.ts new file mode 100644 index 0000000..497d36a --- /dev/null +++ b/cms/src/types/moneroWallet/MoneroWalletRevealSeedPayload.ts @@ -0,0 +1,3 @@ +export interface MoneroWalletRevealSeedPayload { + password: string; +} diff --git a/cms/src/types/moneroWallet/MoneroWalletRevealSeedResult.ts b/cms/src/types/moneroWallet/MoneroWalletRevealSeedResult.ts new file mode 100644 index 0000000..1884638 --- /dev/null +++ b/cms/src/types/moneroWallet/MoneroWalletRevealSeedResult.ts @@ -0,0 +1,3 @@ +export interface MoneroWalletRevealSeedResult { + mnemonic: string; +} diff --git a/cms/src/types/moneroWallet/MoneroWalletStatus.ts b/cms/src/types/moneroWallet/MoneroWalletStatus.ts new file mode 100644 index 0000000..716b95b --- /dev/null +++ b/cms/src/types/moneroWallet/MoneroWalletStatus.ts @@ -0,0 +1,12 @@ +import type { MoneroNetwork } from './MoneroNetwork'; +import type { MoneroWalletSyncStatus } from './MoneroWalletSyncStatus'; + +export interface MoneroWalletStatus { + network: MoneroNetwork; + rpcVersion: string; + walletHeight: number; + daemonHeight: number | null; + syncStatus: MoneroWalletSyncStatus; + balanceXmr: string; + unlockedBalanceXmr: string; +} diff --git a/cms/src/types/moneroWallet/MoneroWalletSyncStatus.ts b/cms/src/types/moneroWallet/MoneroWalletSyncStatus.ts new file mode 100644 index 0000000..c17d00a --- /dev/null +++ b/cms/src/types/moneroWallet/MoneroWalletSyncStatus.ts @@ -0,0 +1,5 @@ +export enum MoneroWalletSyncStatus { + Synced = 'synced', + Syncing = 'syncing', + Unknown = 'unknown' +} diff --git a/cms/src/types/moneroWallet/MoneroWalletWithdrawPayload.ts b/cms/src/types/moneroWallet/MoneroWalletWithdrawPayload.ts new file mode 100644 index 0000000..6e5ebef --- /dev/null +++ b/cms/src/types/moneroWallet/MoneroWalletWithdrawPayload.ts @@ -0,0 +1,4 @@ +export interface MoneroWalletWithdrawPayload { + destinationAddress: string; + password: string; +} diff --git a/cms/src/types/moneroWallet/MoneroWalletWithdrawResult.ts b/cms/src/types/moneroWallet/MoneroWalletWithdrawResult.ts new file mode 100644 index 0000000..d4e6f0e --- /dev/null +++ b/cms/src/types/moneroWallet/MoneroWalletWithdrawResult.ts @@ -0,0 +1,4 @@ +export interface MoneroWalletWithdrawResult { + txHashes: string[]; + amountXmr: string; +} diff --git a/cms/src/types/order/InvoiceState.ts b/cms/src/types/order/InvoiceState.ts new file mode 100644 index 0000000..8385737 --- /dev/null +++ b/cms/src/types/order/InvoiceState.ts @@ -0,0 +1,9 @@ +export type InvoiceState = { + isAwaitingPayment: boolean; + isUnderpaid: boolean; + isPaidSufficient: boolean; + isPaidAwaitingConfirmations: boolean; + isPaidAndConfirmed: boolean; + isExpired: boolean; + hasPendingConfirmations: boolean; +}; diff --git a/cms/src/types/order/ManualLineFulfillmentStatus.ts b/cms/src/types/order/ManualLineFulfillmentStatus.ts new file mode 100644 index 0000000..0cea167 --- /dev/null +++ b/cms/src/types/order/ManualLineFulfillmentStatus.ts @@ -0,0 +1,4 @@ +export enum ManualLineFulfillmentStatus { + Pending = 'pending', + Fulfilled = 'fulfilled' +} diff --git a/cms/src/types/order/OrderDiscount.ts b/cms/src/types/order/OrderDiscount.ts new file mode 100644 index 0000000..6a6fbc4 --- /dev/null +++ b/cms/src/types/order/OrderDiscount.ts @@ -0,0 +1,5 @@ +export type OrderDiscount = { + id: string; + code: string; + amountFiat: number; +}; diff --git a/cms/src/types/order/OrderExtended.ts b/cms/src/types/order/OrderExtended.ts new file mode 100644 index 0000000..1caaf0f --- /dev/null +++ b/cms/src/types/order/OrderExtended.ts @@ -0,0 +1,25 @@ +import type { InvoiceExtended } from '@/types/payment/InvoiceExtended'; +import type { OrderFailureReason } from './OrderFailureReason'; +import type { OrderLine } from './OrderLine'; +import type { OrderMessage } from './OrderMessage'; +import type { OrderState } from './OrderState'; +import type { OrderTotals } from './OrderTotals'; +import type { OrderDiscount } from './OrderDiscount'; + +export type OrderExtended = { + id: string; + state: OrderState; + totals: OrderTotals; + fiatCurrency: string; + failureReason: OrderFailureReason | null; + accessTokenSavedConfirmedAt: string | null; + quotedAt: string | null; + accessToken: string; + lines?: OrderLine[]; + discounts?: OrderDiscount[]; + checkoutInvoice?: InvoiceExtended | null; + shippingInvoice?: InvoiceExtended | null; + messages?: OrderMessage[]; + createdAt: string; + updatedAt: string; +}; diff --git a/cms/src/types/order/OrderFailureReason.ts b/cms/src/types/order/OrderFailureReason.ts new file mode 100644 index 0000000..1a602f7 --- /dev/null +++ b/cms/src/types/order/OrderFailureReason.ts @@ -0,0 +1,4 @@ +export enum OrderFailureReason { + StockUnavailable = 'stock_unavailable', + DiscountExhausted = 'discount_exhausted' +} diff --git a/cms/src/types/order/OrderLine.ts b/cms/src/types/order/OrderLine.ts new file mode 100644 index 0000000..62965d8 --- /dev/null +++ b/cms/src/types/order/OrderLine.ts @@ -0,0 +1,18 @@ +import type { DeliveryMode } from '@/types/product/DeliveryMode'; +import type { OrderLineAutoFulfillmentItem } from './OrderLineAutoFulfillmentItem'; +import type { OrderLineManualFulfillment } from './OrderLineManualFulfillment'; + +export type OrderLine = { + id: string; + productId: string; + variantId: string; + productTitle: string; + variantTitle: string; + thumbnailUrl: string | null; + qty: number; + unitPriceFiat: number; + lineSubtotalFiat: number; + deliveryMode: DeliveryMode; + autoFulfillmentItems: OrderLineAutoFulfillmentItem[]; + manualFulfillment: OrderLineManualFulfillment | null; +}; diff --git a/cms/src/types/order/OrderLineAutoFulfillmentItem.ts b/cms/src/types/order/OrderLineAutoFulfillmentItem.ts new file mode 100644 index 0000000..1e7112a --- /dev/null +++ b/cms/src/types/order/OrderLineAutoFulfillmentItem.ts @@ -0,0 +1,9 @@ +import type { OrderLineAutoFulfillmentItemAttachment } from './OrderLineAutoFulfillmentItemAttachment'; + +export type OrderLineAutoFulfillmentItem = { + id: string; + sortOrder: number; + contentSnapshot: string; + sourceDigitalStockItemId: string; + attachments: OrderLineAutoFulfillmentItemAttachment[]; +}; diff --git a/cms/src/types/order/OrderLineAutoFulfillmentItemAttachment.ts b/cms/src/types/order/OrderLineAutoFulfillmentItemAttachment.ts new file mode 100644 index 0000000..45604cd --- /dev/null +++ b/cms/src/types/order/OrderLineAutoFulfillmentItemAttachment.ts @@ -0,0 +1,8 @@ +export type OrderLineAutoFulfillmentItemAttachment = { + id: string; + storageKey: string; + sourceDigitalStockAttachmentId: string; + originalFilename: string; + mimeType: string; + sizeBytes: number; +}; diff --git a/cms/src/types/order/OrderLineManualFulfillment.ts b/cms/src/types/order/OrderLineManualFulfillment.ts new file mode 100644 index 0000000..cf0b27c --- /dev/null +++ b/cms/src/types/order/OrderLineManualFulfillment.ts @@ -0,0 +1,7 @@ +import type { ManualLineFulfillmentStatus } from './ManualLineFulfillmentStatus'; + +export type OrderLineManualFulfillment = { + id: string; + status: ManualLineFulfillmentStatus; + fulfilledAt: string | null; +}; diff --git a/cms/src/types/order/OrderListItem.ts b/cms/src/types/order/OrderListItem.ts new file mode 100644 index 0000000..d8db87f --- /dev/null +++ b/cms/src/types/order/OrderListItem.ts @@ -0,0 +1,18 @@ +import type { InvoiceStatusLabel } from '@/types/payment/InvoiceStatusLabel'; +import type { OrderFailureReason } from '@/types/order/OrderFailureReason'; +import type { OrderStatus } from '@/types/order/OrderStatus'; + +export type OrderListItem = { + id: string; + status: OrderStatus; + checkoutPaymentLabel: InvoiceStatusLabel | null; + shippingPaymentLabel: InvoiceStatusLabel | null; + totalFiat: number; + grandTotalFiat: number | null; + fiatCurrency: string; + lineCount: number; + unreadMessageCount: number; + failureReason: OrderFailureReason | null; + createdAt: string; + updatedAt: string; +}; diff --git a/cms/src/types/order/OrderMessage.ts b/cms/src/types/order/OrderMessage.ts new file mode 100644 index 0000000..e1fb0b1 --- /dev/null +++ b/cms/src/types/order/OrderMessage.ts @@ -0,0 +1,8 @@ +import type { OrderMessageSender } from './OrderMessageSender'; + +export type OrderMessage = { + id: string; + sender: OrderMessageSender; + body: string; + createdAt: string; +}; diff --git a/cms/src/types/order/OrderMessageSender.ts b/cms/src/types/order/OrderMessageSender.ts new file mode 100644 index 0000000..e093a4b --- /dev/null +++ b/cms/src/types/order/OrderMessageSender.ts @@ -0,0 +1,4 @@ +export enum OrderMessageSender { + Buyer = 'buyer', + Staff = 'staff' +} diff --git a/cms/src/types/order/OrderState.ts b/cms/src/types/order/OrderState.ts new file mode 100644 index 0000000..7c61b72 --- /dev/null +++ b/cms/src/types/order/OrderState.ts @@ -0,0 +1,8 @@ +import type { InvoiceState } from './InvoiceState'; +import type { OrderStatus } from './OrderStatus'; + +export type OrderState = { + status: OrderStatus; + checkoutInvoiceState: InvoiceState | null; + shippingInvoiceState: InvoiceState | null; +}; diff --git a/cms/src/types/order/OrderStatus.ts b/cms/src/types/order/OrderStatus.ts new file mode 100644 index 0000000..9432da9 --- /dev/null +++ b/cms/src/types/order/OrderStatus.ts @@ -0,0 +1,5 @@ +export enum OrderStatus { + Unfulfilled = 'unfulfilled', + Fulfilled = 'fulfilled', + Unfulfillable = 'unfulfillable' +} diff --git a/cms/src/types/order/OrderTotals.ts b/cms/src/types/order/OrderTotals.ts new file mode 100644 index 0000000..7dc0414 --- /dev/null +++ b/cms/src/types/order/OrderTotals.ts @@ -0,0 +1,7 @@ +export type OrderTotals = { + subtotalFiat: number; + discountTotalFiat: number; + totalFiat: number; + shippingCostFiat: number | null; + grandTotalFiat: number | null; +}; diff --git a/cms/src/types/order/SetDeliveryCostPayload.ts b/cms/src/types/order/SetDeliveryCostPayload.ts new file mode 100644 index 0000000..ce7e0c3 --- /dev/null +++ b/cms/src/types/order/SetDeliveryCostPayload.ts @@ -0,0 +1,3 @@ +export interface SetDeliveryCostPayload { + deliveryCost: number; +} diff --git a/cms/src/types/payment/Invoice.ts b/cms/src/types/payment/Invoice.ts new file mode 100644 index 0000000..03c0f3c --- /dev/null +++ b/cms/src/types/payment/Invoice.ts @@ -0,0 +1,18 @@ +import type { InvoiceMoneroDetails } from './InvoiceMoneroDetails'; +import type { InvoicePayment } from './InvoicePayment'; +import type { InvoiceReason } from './InvoiceReason'; +import type { PaymentMethod } from './PaymentMethod'; + +export type Invoice = { + id: string; + reason: InvoiceReason; + paymentMethod: PaymentMethod; + amountFiat: number; + fiatCurrency: string; + expiresAt: string; + paymentAddress: string; + expectedTotalAtomic: string; + createdAt: string; + moneroDetails?: InvoiceMoneroDetails | null; + payments?: InvoicePayment[]; +}; diff --git a/cms/src/types/payment/InvoiceExtended.ts b/cms/src/types/payment/InvoiceExtended.ts new file mode 100644 index 0000000..1210097 --- /dev/null +++ b/cms/src/types/payment/InvoiceExtended.ts @@ -0,0 +1,9 @@ +import type { Invoice } from './Invoice'; +import type { InvoicePaymentExtended } from './InvoicePaymentExtended'; +import type { InvoiceStatusLabel } from './InvoiceStatusLabel'; + +export type InvoiceExtended = Omit & { + statusLabel: InvoiceStatusLabel | null; + expectedTotalCrypto: string; + payments: InvoicePaymentExtended[]; +}; diff --git a/cms/src/types/payment/InvoiceMoneroDetails.ts b/cms/src/types/payment/InvoiceMoneroDetails.ts new file mode 100644 index 0000000..3520ef3 --- /dev/null +++ b/cms/src/types/payment/InvoiceMoneroDetails.ts @@ -0,0 +1,6 @@ +export type InvoiceMoneroDetails = { + id: string; + paymentAddressIndex: number; + fiatPerXmrAtCreation: number; + requiredConfirmations: number; +}; diff --git a/cms/src/types/payment/InvoicePayment.ts b/cms/src/types/payment/InvoicePayment.ts new file mode 100644 index 0000000..53f6668 --- /dev/null +++ b/cms/src/types/payment/InvoicePayment.ts @@ -0,0 +1,7 @@ +export type InvoicePayment = { + id: string; + txHash: string; + amountAtomic: string; + confirmations: number; + createdAt: string; +}; diff --git a/cms/src/types/payment/InvoicePaymentExtended.ts b/cms/src/types/payment/InvoicePaymentExtended.ts new file mode 100644 index 0000000..58d3bb4 --- /dev/null +++ b/cms/src/types/payment/InvoicePaymentExtended.ts @@ -0,0 +1,7 @@ +import type { InvoicePayment } from './InvoicePayment'; + +export type InvoicePaymentExtended = InvoicePayment & { + amountCrypto: string; + isConfirmed: boolean; + confirmationsLabel: string; +}; diff --git a/cms/src/types/payment/InvoiceReason.ts b/cms/src/types/payment/InvoiceReason.ts new file mode 100644 index 0000000..ac546da --- /dev/null +++ b/cms/src/types/payment/InvoiceReason.ts @@ -0,0 +1,4 @@ +export enum InvoiceReason { + Checkout = 'checkout', + Shipping = 'shipping' +} diff --git a/cms/src/types/payment/InvoiceStatusLabel.ts b/cms/src/types/payment/InvoiceStatusLabel.ts new file mode 100644 index 0000000..d0e56ad --- /dev/null +++ b/cms/src/types/payment/InvoiceStatusLabel.ts @@ -0,0 +1,6 @@ +export type InvoiceStatusLabel = + | 'Payment confirmed' + | 'Awaiting confirmations' + | 'Partial payment received' + | 'Payment expired' + | 'Awaiting payment'; diff --git a/cms/src/types/payment/PaymentMethod.ts b/cms/src/types/payment/PaymentMethod.ts new file mode 100644 index 0000000..ca816cc --- /dev/null +++ b/cms/src/types/payment/PaymentMethod.ts @@ -0,0 +1,7 @@ +export enum PaymentMethod { + Xmr = 'xmr' +} + +export const paymentMethodCryptoCurrency: Record = { + [PaymentMethod.Xmr]: 'XMR' +}; diff --git a/cms/src/types/product/Category.ts b/cms/src/types/product/Category.ts new file mode 100644 index 0000000..6457b4a --- /dev/null +++ b/cms/src/types/product/Category.ts @@ -0,0 +1,7 @@ +export type Category = { + id: string; + name: string; + sortOrder: number; + createdAt: string; + updatedAt: string; +}; diff --git a/cms/src/types/product/DeliveryMode.ts b/cms/src/types/product/DeliveryMode.ts new file mode 100644 index 0000000..3261d24 --- /dev/null +++ b/cms/src/types/product/DeliveryMode.ts @@ -0,0 +1,4 @@ +export enum DeliveryMode { + Auto = 'auto', + Manual = 'manual' +} diff --git a/cms/src/types/product/DigitalStockAttachment.ts b/cms/src/types/product/DigitalStockAttachment.ts new file mode 100644 index 0000000..6332a63 --- /dev/null +++ b/cms/src/types/product/DigitalStockAttachment.ts @@ -0,0 +1,7 @@ +export interface DigitalStockAttachment { + id: string; + originalFilename: string; + mimeType: string; + sizeBytes: number; + createdAt: string; +} diff --git a/cms/src/types/product/DigitalStockItem.ts b/cms/src/types/product/DigitalStockItem.ts new file mode 100644 index 0000000..ee9fcd8 --- /dev/null +++ b/cms/src/types/product/DigitalStockItem.ts @@ -0,0 +1,10 @@ +import type { DigitalStockAttachment } from './DigitalStockAttachment'; + +export interface DigitalStockItem { + id: string; + content: string; + isSold: boolean; + createdAt: string; + updatedAt: string; + attachments?: DigitalStockAttachment[]; +} diff --git a/cms/src/types/product/DigitalStockListQuery.ts b/cms/src/types/product/DigitalStockListQuery.ts new file mode 100644 index 0000000..65ab258 --- /dev/null +++ b/cms/src/types/product/DigitalStockListQuery.ts @@ -0,0 +1,5 @@ +export type DigitalStockListQuery = { + page: number; + limit: number; + hideSold: boolean; +}; diff --git a/cms/src/types/product/PendingImageAction.ts b/cms/src/types/product/PendingImageAction.ts new file mode 100644 index 0000000..57be99b --- /dev/null +++ b/cms/src/types/product/PendingImageAction.ts @@ -0,0 +1,4 @@ +export type PendingImageAction = + | { type: 'thumbnail'; imageId: string } + | { type: 'delete'; imageId: string } + | { type: 'reorder'; imageId: string }; diff --git a/cms/src/types/product/Product.ts b/cms/src/types/product/Product.ts new file mode 100644 index 0000000..0f03aa3 --- /dev/null +++ b/cms/src/types/product/Product.ts @@ -0,0 +1,15 @@ +import type { DeliveryMode } from './DeliveryMode'; +import type { ProductVariantExtended } from './ProductVariantExtended'; +import type { Category } from './Category'; + +export interface Product { + id: string; + title: string; + deliveryMode: DeliveryMode; + isDraft: boolean; + descriptionHtml: string; + variants?: ProductVariantExtended[]; + categories?: Category[]; + createdAt: string; + updatedAt: string; +} diff --git a/cms/src/types/product/ProductOption.ts b/cms/src/types/product/ProductOption.ts new file mode 100644 index 0000000..ac0780a --- /dev/null +++ b/cms/src/types/product/ProductOption.ts @@ -0,0 +1,4 @@ +export type ProductOption = { + id: string; + title: string; +}; diff --git a/cms/src/types/product/ProductVariant.ts b/cms/src/types/product/ProductVariant.ts new file mode 100644 index 0000000..7b0c55d --- /dev/null +++ b/cms/src/types/product/ProductVariant.ts @@ -0,0 +1,16 @@ +import type { DigitalStockItem } from './DigitalStockItem'; +import type { Product } from './Product'; +import type { VariantImage } from './VariantImage'; + +export interface ProductVariant { + id: string; + title: string; + price: number; + stockQuantity: number | null; + sortOrder: number; + createdAt: string; + updatedAt: string; + digitalStockItems?: DigitalStockItem[]; + images?: VariantImage[]; + product?: Product; +} diff --git a/cms/src/types/product/ProductVariantExtended.ts b/cms/src/types/product/ProductVariantExtended.ts new file mode 100644 index 0000000..ae58ef0 --- /dev/null +++ b/cms/src/types/product/ProductVariantExtended.ts @@ -0,0 +1,5 @@ +import type { ProductVariant } from './ProductVariant'; + +export interface ProductVariantExtended extends ProductVariant { + stockAvailable: number; +} diff --git a/cms/src/types/product/ProductVariantPayload.ts b/cms/src/types/product/ProductVariantPayload.ts new file mode 100644 index 0000000..3394af2 --- /dev/null +++ b/cms/src/types/product/ProductVariantPayload.ts @@ -0,0 +1,6 @@ +export interface ProductVariantPayload { + title: string; + price: number; + stockQuantity: number; + sortOrder: number; +} diff --git a/cms/src/types/product/ProductWithVariantsExtended.ts b/cms/src/types/product/ProductWithVariantsExtended.ts new file mode 100644 index 0000000..28fddec --- /dev/null +++ b/cms/src/types/product/ProductWithVariantsExtended.ts @@ -0,0 +1,6 @@ +import type { Product } from './Product'; +import type { ProductVariantExtended } from './ProductVariantExtended'; + +export interface ProductWithVariantsExtended extends Omit { + variants: ProductVariantExtended[]; +} diff --git a/cms/src/types/product/UpdateProductPayload.ts b/cms/src/types/product/UpdateProductPayload.ts new file mode 100644 index 0000000..8d1d136 --- /dev/null +++ b/cms/src/types/product/UpdateProductPayload.ts @@ -0,0 +1,6 @@ +export interface UpdateProductPayload { + title: string; + isDraft: boolean; + descriptionHtml: string; + categoryIds: string[]; +} diff --git a/cms/src/types/product/VariantImage.ts b/cms/src/types/product/VariantImage.ts new file mode 100644 index 0000000..b7689fd --- /dev/null +++ b/cms/src/types/product/VariantImage.ts @@ -0,0 +1,8 @@ +export interface VariantImage { + id: string; + url: string; + sortOrder: number; + isThumbnail: boolean; + createdAt: string; + updatedAt: string; +} diff --git a/cms/src/types/product/VariantOption.ts b/cms/src/types/product/VariantOption.ts new file mode 100644 index 0000000..dea2fb0 --- /dev/null +++ b/cms/src/types/product/VariantOption.ts @@ -0,0 +1,4 @@ +export type VariantOption = { + id: string; + label: string; +}; diff --git a/cms/src/types/shopSettings/ConnectSimplexNotificationsPayload.ts b/cms/src/types/shopSettings/ConnectSimplexNotificationsPayload.ts new file mode 100644 index 0000000..3b44277 --- /dev/null +++ b/cms/src/types/shopSettings/ConnectSimplexNotificationsPayload.ts @@ -0,0 +1,3 @@ +export interface ConnectSimplexNotificationsPayload { + simplexNotificationLink: string; +} diff --git a/cms/src/types/shopSettings/MoneroConfirmationTier.ts b/cms/src/types/shopSettings/MoneroConfirmationTier.ts new file mode 100644 index 0000000..776773d --- /dev/null +++ b/cms/src/types/shopSettings/MoneroConfirmationTier.ts @@ -0,0 +1,4 @@ +export interface MoneroConfirmationTier { + upToTotalFiat?: string; + minConfirmations: number; +} diff --git a/cms/src/types/shopSettings/SetupChecklist.ts b/cms/src/types/shopSettings/SetupChecklist.ts new file mode 100644 index 0000000..f58f332 --- /dev/null +++ b/cms/src/types/shopSettings/SetupChecklist.ts @@ -0,0 +1,6 @@ +export interface SetupChecklist { + logo: boolean; + favicon: boolean; + simplexLink: boolean; + shippingNote: boolean; +} diff --git a/cms/src/types/shopSettings/ShopSettings.ts b/cms/src/types/shopSettings/ShopSettings.ts new file mode 100644 index 0000000..2e41c52 --- /dev/null +++ b/cms/src/types/shopSettings/ShopSettings.ts @@ -0,0 +1,22 @@ +import type { ShopSettingsMonero } from './ShopSettingsMonero'; +import type { SetupChecklist } from './SetupChecklist'; + +export interface ShopSettings { + id: string | null; + shopName: string; + shopFiatCurrency: string; + monero: ShopSettingsMonero; + logoUrl: string | null; + faviconUrl: string | null; + simplexLink: string | null; + simplexNotificationLink: string | null; + shippingNote: string | null; + notificationsEnabled: boolean; + notifyOnNewOrder: boolean; + notifyOnOrderMessage: boolean; + simplexNotificationConnected: boolean; + isSetupComplete: boolean; + setupChecklist: SetupChecklist; + createdAt: string | null; + updatedAt: string | null; +} diff --git a/cms/src/types/shopSettings/ShopSettingsMonero.ts b/cms/src/types/shopSettings/ShopSettingsMonero.ts new file mode 100644 index 0000000..14199e7 --- /dev/null +++ b/cms/src/types/shopSettings/ShopSettingsMonero.ts @@ -0,0 +1,5 @@ +import type { MoneroConfirmationTier } from './MoneroConfirmationTier'; + +export interface ShopSettingsMonero { + confirmationTiers: MoneroConfirmationTier[]; +} diff --git a/cms/src/types/shopSettings/UpdateNotificationsPayload.ts b/cms/src/types/shopSettings/UpdateNotificationsPayload.ts new file mode 100644 index 0000000..6361878 --- /dev/null +++ b/cms/src/types/shopSettings/UpdateNotificationsPayload.ts @@ -0,0 +1,5 @@ +export interface UpdateNotificationsPayload { + notificationsEnabled: boolean; + notifyOnNewOrder: boolean; + notifyOnOrderMessage: boolean; +} diff --git a/cms/src/types/shopSettings/UpdateShippingNotePayload.ts b/cms/src/types/shopSettings/UpdateShippingNotePayload.ts new file mode 100644 index 0000000..bf612ba --- /dev/null +++ b/cms/src/types/shopSettings/UpdateShippingNotePayload.ts @@ -0,0 +1,3 @@ +export interface UpdateShippingNotePayload { + shippingNote: string; +} diff --git a/cms/src/types/shopSettings/UpdateSimplexLinkPayload.ts b/cms/src/types/shopSettings/UpdateSimplexLinkPayload.ts new file mode 100644 index 0000000..e26bde3 --- /dev/null +++ b/cms/src/types/shopSettings/UpdateSimplexLinkPayload.ts @@ -0,0 +1,3 @@ +export interface UpdateSimplexLinkPayload { + simplexLink: string; +} diff --git a/cms/src/utils/capitalizeFirstLetter.ts b/cms/src/utils/capitalizeFirstLetter.ts new file mode 100644 index 0000000..e4181bd --- /dev/null +++ b/cms/src/utils/capitalizeFirstLetter.ts @@ -0,0 +1,2 @@ +export const capitalizeFirstLetter = (value: string): string => + value.charAt(0).toUpperCase() + value.slice(1); diff --git a/cms/src/utils/formatDate.ts b/cms/src/utils/formatDate.ts new file mode 100644 index 0000000..53a2815 --- /dev/null +++ b/cms/src/utils/formatDate.ts @@ -0,0 +1,3 @@ +import dayjs from '@/plugins/dayjs'; + +export const formatDate = (iso: string): string => dayjs(iso).format('YYYY-MM-DD HH:mm'); diff --git a/cms/src/utils/formatFiatPrice.ts b/cms/src/utils/formatFiatPrice.ts new file mode 100644 index 0000000..848ea3e --- /dev/null +++ b/cms/src/utils/formatFiatPrice.ts @@ -0,0 +1,11 @@ +import Decimal from 'decimal.js'; + +export const formatFiatPrice = (price: number, unit: string): string => { + const rounded = new Decimal(price).toDecimalPlaces(2); + + try { + return new Intl.NumberFormat('en-US', { style: 'currency', currency: unit }).format(rounded.toNumber()); + } catch { + return `${rounded.toString()} ${unit}`; + } +}; diff --git a/cms/src/utils/formatFileSize.ts b/cms/src/utils/formatFileSize.ts new file mode 100644 index 0000000..6751234 --- /dev/null +++ b/cms/src/utils/formatFileSize.ts @@ -0,0 +1,4 @@ +import prettyBytes from 'pretty-bytes'; + +export const formatFileSize = (sizeBytes: number): string => + prettyBytes(sizeBytes, { binary: true, maximumFractionDigits: 0 }); diff --git a/cms/src/utils/formatRelativeTimeAgo.ts b/cms/src/utils/formatRelativeTimeAgo.ts new file mode 100644 index 0000000..d54e785 --- /dev/null +++ b/cms/src/utils/formatRelativeTimeAgo.ts @@ -0,0 +1,3 @@ +import dayjs from '@/plugins/dayjs'; + +export const formatRelativeTimeAgo = (iso: string): string => dayjs(iso).fromNow(); diff --git a/cms/src/utils/getPaginationLastPage.ts b/cms/src/utils/getPaginationLastPage.ts new file mode 100644 index 0000000..e52accf --- /dev/null +++ b/cms/src/utils/getPaginationLastPage.ts @@ -0,0 +1,2 @@ +export const getPaginationLastPage = (itemTotal: number, pageLimit: number): number => + Math.max(1, Math.ceil(itemTotal / pageLimit)); diff --git a/cms/src/utils/isSet.ts b/cms/src/utils/isSet.ts new file mode 100644 index 0000000..6bc6c16 --- /dev/null +++ b/cms/src/utils/isSet.ts @@ -0,0 +1 @@ +export const isSet = (value: T | null | undefined): value is T => value !== null && value !== undefined; diff --git a/cms/src/utils/monero/isMoneroStandardAddress.ts b/cms/src/utils/monero/isMoneroStandardAddress.ts new file mode 100644 index 0000000..d30f544 --- /dev/null +++ b/cms/src/utils/monero/isMoneroStandardAddress.ts @@ -0,0 +1,19 @@ +import type { MoneroNetwork } from '@/types/moneroWallet/MoneroNetwork'; + +// Standard + subaddress, 95 chars. Excludes integrated (106 chars). +// Prefix bytes: monero-project/monero src/cryptonote_config.h +// Regex shape: https://gist.github.com/masflam/84477ca88842e245dc7a4cc61ce299e3 +const BASE58 = '[1-9A-HJ-NP-Za-km-z]'; + +const NETWORK_ADDRESS_PATTERNS: Record = { + mainnet: new RegExp(`^(?:4[1-9AB]|8[2-9ABC])${BASE58}{93}$`), + stagenet: new RegExp(`^(?:5[1-9AB]|7[2-9AB])${BASE58}{93}$`) +}; + +export const isMoneroStandardAddress = (value: unknown, network: MoneroNetwork): boolean => { + if (typeof value !== 'string') { + return false; + } + + return NETWORK_ADDRESS_PATTERNS[network].test(value.trim()); +}; diff --git a/cms/src/utils/order/formatOrderFailureReason.ts b/cms/src/utils/order/formatOrderFailureReason.ts new file mode 100644 index 0000000..38e273f --- /dev/null +++ b/cms/src/utils/order/formatOrderFailureReason.ts @@ -0,0 +1,8 @@ +import { OrderFailureReason } from '@/types/order/OrderFailureReason'; + +const labels: Record = { + [OrderFailureReason.StockUnavailable]: 'Stock unavailable', + [OrderFailureReason.DiscountExhausted]: 'Discount exhausted' +}; + +export const formatOrderFailureReason = (reason: OrderFailureReason): string => labels[reason] ?? reason; diff --git a/cms/src/utils/order/resolveInvoiceStatusTagType.ts b/cms/src/utils/order/resolveInvoiceStatusTagType.ts new file mode 100644 index 0000000..d1fcb98 --- /dev/null +++ b/cms/src/utils/order/resolveInvoiceStatusTagType.ts @@ -0,0 +1,19 @@ +import type { InvoiceStatusLabel } from '@/types/payment/InvoiceStatusLabel'; + +export const resolveInvoiceStatusTagType = ( + statusLabel: InvoiceStatusLabel | null +): 'success' | 'warning' | 'info' | 'danger' => { + switch (statusLabel) { + case 'Payment confirmed': + return 'success'; + case 'Awaiting confirmations': + case 'Partial payment received': + return 'warning'; + case 'Payment expired': + return 'danger'; + case 'Awaiting payment': + return 'info'; + default: + return 'info'; + } +}; diff --git a/cms/src/utils/order/resolveOrderStatusTagType.ts b/cms/src/utils/order/resolveOrderStatusTagType.ts new file mode 100644 index 0000000..d227cbd --- /dev/null +++ b/cms/src/utils/order/resolveOrderStatusTagType.ts @@ -0,0 +1,14 @@ +import { OrderStatus } from '@/types/order/OrderStatus'; + +export const resolveOrderStatusTagType = (status: OrderStatus): 'success' | 'warning' | 'info' | 'danger' => { + switch (status) { + case OrderStatus.Fulfilled: + return 'success'; + case OrderStatus.Unfulfilled: + return 'warning'; + case OrderStatus.Unfulfillable: + return 'danger'; + default: + return 'info'; + } +}; diff --git a/cms/src/utils/product/compareProductVariants.ts b/cms/src/utils/product/compareProductVariants.ts new file mode 100644 index 0000000..5f188bb --- /dev/null +++ b/cms/src/utils/product/compareProductVariants.ts @@ -0,0 +1,4 @@ +import type { ProductVariant } from '@/types/product/ProductVariant'; + +export const compareProductVariants = (a: ProductVariant, b: ProductVariant): number => + a.sortOrder - b.sortOrder || a.createdAt.localeCompare(b.createdAt); diff --git a/cms/src/utils/product/formatDeliveryMode.ts b/cms/src/utils/product/formatDeliveryMode.ts new file mode 100644 index 0000000..39c2cae --- /dev/null +++ b/cms/src/utils/product/formatDeliveryMode.ts @@ -0,0 +1,9 @@ +import { DeliveryMode } from '@/types/product/DeliveryMode'; + +export const formatDeliveryMode = (deliveryMode: DeliveryMode): string => { + if (deliveryMode === DeliveryMode.Manual) { + return 'Manually fulfilled'; + } + + return 'Auto-delivered'; +}; diff --git a/cms/src/utils/product/getProductTitle.ts b/cms/src/utils/product/getProductTitle.ts new file mode 100644 index 0000000..683cc7a --- /dev/null +++ b/cms/src/utils/product/getProductTitle.ts @@ -0,0 +1,3 @@ +import { UNTITLED_PRODUCT_TITLE } from '@/consts/untitledProductTitle'; + +export const getProductTitle = (title: string | undefined | null): string => title || UNTITLED_PRODUCT_TITLE; diff --git a/cms/src/utils/product/getVariantLabel.ts b/cms/src/utils/product/getVariantLabel.ts new file mode 100644 index 0000000..090f336 --- /dev/null +++ b/cms/src/utils/product/getVariantLabel.ts @@ -0,0 +1,4 @@ +import { getProductTitle } from '@/utils/product/getProductTitle'; + +export const getVariantLabel = (variantTitle: string, productTitle: string | undefined | null): string => + `${getProductTitle(productTitle)} — ${variantTitle}`; diff --git a/cms/src/utils/resolveAxiosErrorMessage.ts b/cms/src/utils/resolveAxiosErrorMessage.ts new file mode 100644 index 0000000..fc0f7df --- /dev/null +++ b/cms/src/utils/resolveAxiosErrorMessage.ts @@ -0,0 +1,39 @@ +import { isAxiosError } from 'axios'; + +const getAxiosErrorResponseMessage = (data: unknown): string | null => { + if (typeof data === 'string') { + return data; + } + + if (typeof data !== 'object' || data === null || !('message' in data)) { + return null; + } + + const { message } = data; + + if (typeof message === 'string') { + return message; + } + + if (Array.isArray(message)) { + const texts = message.filter((item): item is string => typeof item === 'string'); + + if (texts.length === 1) { + return texts[0]; + } + + if (texts.length > 1) { + return texts.join(', '); + } + } + + return null; +}; + +export const resolveAxiosErrorMessage = (error: unknown, fallback: string): string => { + if (!isAxiosError(error) || !error.response) { + return fallback; + } + + return getAxiosErrorResponseMessage(error.response.data) ?? fallback; +}; diff --git a/cms/src/utils/upload/buildUploadHint.ts b/cms/src/utils/upload/buildUploadHint.ts new file mode 100644 index 0000000..8e78541 --- /dev/null +++ b/cms/src/utils/upload/buildUploadHint.ts @@ -0,0 +1,72 @@ +import type { BuildUploadHintOptions } from '@/types/BuildUploadHintOptions'; +import { formatFileSize } from '@/utils/formatFileSize'; + +const MIME_LABEL: Record = { + 'application/json': 'JSON', + 'application/pdf': 'PDF', + 'application/zip': 'ZIP', + 'image/gif': 'GIF', + 'image/jpeg': 'JPEG', + 'image/jpg': 'JPEG', + 'image/png': 'PNG', + 'image/vnd.microsoft.icon': 'ICO', + 'image/x-icon': 'ICO', + 'image/webp': 'WebP', + 'text/csv': 'CSV', + 'text/plain': 'plain text' +}; + +const formatTypeList = (types: string[]): string => { + if (types.length === 0) { + return ''; + } + + if (types.length === 1) { + return types[0]; + } + + if (types.length === 2) { + return `${types[0]} or ${types[1]}`; + } + + return `${types.slice(0, -1).join(', ')}, or ${types.at(-1)}`; +}; + +export const buildUploadHint = ({ + allowedMimesCsv, + maxFileBytes, + maxFiles, + maxFilesLabel, + encryptedAtRest +}: BuildUploadHintOptions): string => { + const types = [ + ...new Set( + allowedMimesCsv + .split(',') + .map(s => s.trim().toLowerCase()) + .filter(Boolean) + .map(m => MIME_LABEL[m] ?? m) + ) + ]; + + const sizePart = formatFileSize(maxFileBytes); + const typePart = formatTypeList(types) || allowedMimesCsv.trim(); + + const parts: string[] = []; + + if (typePart) { + parts.push(`${typePart} up to ${sizePart} each.`); + } else { + parts.push(`Up to ${sizePart} each.`); + } + + if (maxFiles !== undefined && maxFilesLabel) { + parts.push(`Max ${maxFiles} ${maxFilesLabel}.`); + } + + if (encryptedAtRest) { + parts.push('Encrypted at rest on the server.'); + } + + return parts.join(' '); +}; diff --git a/cms/src/utils/upload/resolveUploadPublicUrl.ts b/cms/src/utils/upload/resolveUploadPublicUrl.ts new file mode 100644 index 0000000..fe6ccf0 --- /dev/null +++ b/cms/src/utils/upload/resolveUploadPublicUrl.ts @@ -0,0 +1,3 @@ +import { config } from '@/config'; + +export const resolveUploadPublicUrl = (publicPath: string): string => `${config.api.rootUrl}${publicPath}`; diff --git a/cms/src/utils/upload/validateUpload.ts b/cms/src/utils/upload/validateUpload.ts new file mode 100644 index 0000000..4fdac43 --- /dev/null +++ b/cms/src/utils/upload/validateUpload.ts @@ -0,0 +1,25 @@ +import type { UploadValidationOptions } from '@/types/UploadValidationOptions'; +import { formatFileSize } from '@/utils/formatFileSize'; + +export const validateUpload = (file: File, opts: UploadValidationOptions): string | null => { + const { allowedMimesCsv, maxFileBytes } = opts; + + const allowed = allowedMimesCsv + .split(',') + .map(s => s.trim().toLowerCase()) + .filter(Boolean); + + if (allowed.length > 0) { + const type = file.type.toLowerCase(); + + if (!type || !allowed.includes(type)) { + return 'That file type is not allowed.'; + } + } + + if (file.size > maxFileBytes) { + return `File is too large (max ${formatFileSize(maxFileBytes)}).`; + } + + return null; +}; diff --git a/cms/src/views/CmsCategoriesView.vue b/cms/src/views/CmsCategoriesView.vue new file mode 100644 index 0000000..73825e6 --- /dev/null +++ b/cms/src/views/CmsCategoriesView.vue @@ -0,0 +1,109 @@ + + + diff --git a/cms/src/views/CmsDiscountCodesView.vue b/cms/src/views/CmsDiscountCodesView.vue new file mode 100644 index 0000000..f24340e --- /dev/null +++ b/cms/src/views/CmsDiscountCodesView.vue @@ -0,0 +1,274 @@ + + + diff --git a/cms/src/views/CmsLoginView.vue b/cms/src/views/CmsLoginView.vue new file mode 100644 index 0000000..8a89096 --- /dev/null +++ b/cms/src/views/CmsLoginView.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/cms/src/views/CmsNotificationsView.vue b/cms/src/views/CmsNotificationsView.vue new file mode 100644 index 0000000..2d0f7db --- /dev/null +++ b/cms/src/views/CmsNotificationsView.vue @@ -0,0 +1,233 @@ + + + diff --git a/cms/src/views/CmsOrderDetailView.vue b/cms/src/views/CmsOrderDetailView.vue new file mode 100644 index 0000000..b9ac7db --- /dev/null +++ b/cms/src/views/CmsOrderDetailView.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/cms/src/views/CmsOrdersView.vue b/cms/src/views/CmsOrdersView.vue new file mode 100644 index 0000000..8a8ed82 --- /dev/null +++ b/cms/src/views/CmsOrdersView.vue @@ -0,0 +1,134 @@ + + + diff --git a/cms/src/views/CmsProductDetailView.vue b/cms/src/views/CmsProductDetailView.vue new file mode 100644 index 0000000..c0db755 --- /dev/null +++ b/cms/src/views/CmsProductDetailView.vue @@ -0,0 +1,296 @@ + + + diff --git a/cms/src/views/CmsProductVariantDetailView.vue b/cms/src/views/CmsProductVariantDetailView.vue new file mode 100644 index 0000000..15127c9 --- /dev/null +++ b/cms/src/views/CmsProductVariantDetailView.vue @@ -0,0 +1,133 @@ + + + diff --git a/cms/src/views/CmsProductsView.vue b/cms/src/views/CmsProductsView.vue new file mode 100644 index 0000000..92c5226 --- /dev/null +++ b/cms/src/views/CmsProductsView.vue @@ -0,0 +1,176 @@ + + + + + diff --git a/cms/src/views/CmsSettingsLayout.vue b/cms/src/views/CmsSettingsLayout.vue new file mode 100644 index 0000000..9078c91 --- /dev/null +++ b/cms/src/views/CmsSettingsLayout.vue @@ -0,0 +1,32 @@ + + + diff --git a/cms/src/views/CmsShopSettingsView.vue b/cms/src/views/CmsShopSettingsView.vue new file mode 100644 index 0000000..1f6f7e5 --- /dev/null +++ b/cms/src/views/CmsShopSettingsView.vue @@ -0,0 +1,488 @@ + + + + + diff --git a/cms/src/views/CmsWalletView.vue b/cms/src/views/CmsWalletView.vue new file mode 100644 index 0000000..d0655e4 --- /dev/null +++ b/cms/src/views/CmsWalletView.vue @@ -0,0 +1,7 @@ + diff --git a/cms/src/vite-env.d.ts b/cms/src/vite-env.d.ts new file mode 100644 index 0000000..e1552b8 --- /dev/null +++ b/cms/src/vite-env.d.ts @@ -0,0 +1,37 @@ +/// + +import 'vue-router'; + +declare module 'vue-router' { + interface RouteMeta { + title?: string; + requiresAuth?: boolean; + hideNav?: boolean; + activeMenu?: string; + } +} + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL?: string; + readonly VITE_PRODUCT_THUMB_ALLOWED_MIMES?: string; + readonly VITE_PRODUCT_THUMB_MAX_FILE_BYTES?: string; + readonly VITE_SHOP_LOGO_ALLOWED_MIMES?: string; + readonly VITE_SHOP_LOGO_MAX_FILE_BYTES?: string; + readonly VITE_SHOP_FAVICON_ALLOWED_MIMES?: string; + readonly VITE_SHOP_FAVICON_MAX_FILE_BYTES?: string; + readonly VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES?: string; + readonly VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES?: string; + readonly VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH?: string; + readonly VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH?: string; + readonly VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH?: string; + readonly VITE_VALIDATION_VARIANT_IMAGES_MAX?: string; + readonly VITE_VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX?: string; + readonly VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH?: string; + readonly VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH?: string; + readonly VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH?: string; + readonly VITE_SHOP_FIAT_CURRENCY?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/cms/tsconfig.app.json b/cms/tsconfig.app.json new file mode 100644 index 0000000..3a77142 --- /dev/null +++ b/cms/tsconfig.app.json @@ -0,0 +1,16 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"], + "paths": { + "@/*": ["./src/*"] + }, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/**/*.d.ts"] +} diff --git a/cms/tsconfig.json b/cms/tsconfig.json new file mode 100644 index 0000000..eb69b0d --- /dev/null +++ b/cms/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/cms/tsconfig.node.json b/cms/tsconfig.node.json new file mode 100644 index 0000000..63719f2 --- /dev/null +++ b/cms/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/cms/vite.config.ts b/cms/vite.config.ts new file mode 100644 index 0000000..cab791c --- /dev/null +++ b/cms/vite.config.ts @@ -0,0 +1,24 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import Components from 'unplugin-vue-components/vite'; +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + base: '/cms', + resolve: { + alias: { + '@': path.resolve(__dirname, 'src') + } + }, + plugins: [ + vue(), + Components({ + dts: 'src/components.d.ts', + resolvers: [ElementPlusResolver()] + }) + ] +}); diff --git a/deploy/DEPLOYMENT_GUIDE.md b/deploy/DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..d870800 --- /dev/null +++ b/deploy/DEPLOYMENT_GUIDE.md @@ -0,0 +1,166 @@ +# Deployment guide + +## 1. Requirements + +- Ubuntu 22.04+ (or similar Linux) +- Docker Engine and Compose plugin — follow [Install Docker Engine on Ubuntu](https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository) +- A domain name pointing at your server (A record for clearnet HTTPS) + +## 2. Server setup + +Deploy as **root** on the VPS. Docker Engine and Compose plugin must be installed — [Install Docker Engine on Ubuntu](https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository). + +Verify: + +```bash +docker compose version +``` + +## 3. Clone the repository + +```bash +cd /root +git clone https://github.com//nullcart.git nullcart +cd nullcart +``` + +Replace `/nullcart` with your actual repository URL once published. + +## 4. Configure environment + +```bash +cd /root/nullcart +cp .env.example .env.prod +chmod 600 .env.prod +``` + +Edit `.env.prod`. Mandatory configuration: + +| Variable | Production value | +| ---------------------------- | ------------------------------------------- | +| `COMPOSE_PROJECT_NAME` | `nullcart_prod` | +| `POSTGRES_PASSWORD` | strong random password | +| `POSTGRES_MIGRATIONS_RUN` | `true` | +| `PGADMIN_DEFAULT_PASSWORD` | strong random password | +| `NODE_ENV` | `production` | +| `CORS_ORIGINS` | `https://your-domain.com` | +| `CLEARNET_DOMAIN` | `your-domain.com` | +| `JWT_SECRET` | strong random secret | +| `CMS_PASSWORD` | strong admin password | +| `SHOP_NAME` | your shop name | +| `SHOP_FIAT_CURRENCY` | `USD`, `EUR`, `GBP`, `CAD`, `AUD`, or `CHF` | +| `SIGNED_COOKIE_JWT_SECRET` | strong random secret | +| `BASE64_ENCRYPTION_KEY` | generate with `openssl rand -base64 32` | +| `MONERO_NETWORK` | `mainnet` | +| `MONERO_DAEMON_ADDRESS` | mainnet node `host:port` | +| `MONERO_WALLET_RPC_USERNAME` | strong random username | +| `MONERO_WALLET_RPC_PASSWORD` | strong random password | +| `MONERO_WALLET_PASSWORD` | strong wallet password | +| `VITE_API_BASE_URL` | `/api` | +| `VITE_SHOP_FIAT_CURRENCY` | same as `SHOP_FIAT_CURRENCY` | + +Optional — adjust Monero payment confirmation rules: + +**`MONERO_CONFIRMATION_TIERS`** — JSON array. For each order, the shop uses `minConfirmations` from the first tier where the order total (in `SHOP_FIAT_CURRENCY`) is `<= upToTotalFiat`. The last tier is a catch-all and must omit `upToTotalFiat`. At most one tier may use `minConfirmations: 0` (accept on mempool); that tier cannot be the catch-all. + +Example (default in `.env.example`): + +```json +[ + { "upToTotalFiat": "30", "minConfirmations": 0 }, + { "upToTotalFiat": "100", "minConfirmations": 3 }, + { "upToTotalFiat": "300", "minConfirmations": 5 }, + { "minConfirmations": 10 } +] +``` + +Orders up to 30 → 0 confirmations; up to 100 → 3; up to 300 → 5; above 300 → 10. Tiers are shown read-only in CMS shop settings. + +## 5. Create the Monero wallet + +```bash +./monero-wallet-rpc/setup-monero-wallet.sh --env-file .env.prod +``` + +## 6. Bootstrap TLS certificates + +Nginx needs certificate files before it can start on port 443. For the **first** deploy, create a temporary self-signed pair (replaced after Let's Encrypt): + +```bash +./deploy/scripts/bootstrap-certs.sh +``` + +After the stack is running, obtain real certificates (step 8). + +## 7. Start the stack + +```bash +./deploy/scripts/deploy.sh +``` + +Wait until `backend` and `nginx` are healthy: + +```bash +docker compose --env-file .env.prod -f docker-compose.prod.yml ps +``` + +## 8. Issue Let's Encrypt certificates + +Remove the temporary bootstrap certificates under `deploy/certs/live/` (Certbot cannot issue into the layout created by `bootstrap-certs.sh`): + +```bash +rm -rf deploy/certs/live/* +``` + +Request the real certificate: + +```bash +./deploy/scripts/issue-certs.sh --email you@example.com +``` + +Reload nginx: + +```bash +docker compose --env-file .env.prod -f docker-compose.prod.yml exec nginx nginx -s reload +``` + +### Automatic renewal + +Open root's crontab: + +```bash +crontab -e +``` + +Add a weekly job (`/root/nullcart` is the standard deploy path): + +```cron +0 3 * * 0 /root/nullcart/deploy/scripts/renew-certs.sh >> /var/log/nullcart-cert-renew.log 2>&1 +``` + +Save and exit the editor. Optional — run once manually to verify: + +```bash +/root/nullcart/deploy/scripts/renew-certs.sh +``` + +## 9. Tor onion address + +```bash +./deploy/scripts/show-onion.sh +``` + +## 10. Complete shop setup + +1. Open the CMS on clearnet or onion (`/cms`). +2. Log in with `CMS_PASSWORD` from `.env.prod`. +3. Finish the setup checklist in settings. +4. Connect SimpleX notifications in shop settings. + +## 11. Updates + +```bash +./deploy/scripts/update.sh +``` + +This pulls the latest code and rebuilds the stack (`deploy.sh`). diff --git a/deploy/scripts/bootstrap-certs.sh b/deploy/scripts/bootstrap-certs.sh new file mode 100755 index 0000000..e211824 --- /dev/null +++ b/deploy/scripts/bootstrap-certs.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ENV_FILE="${ROOT_DIR}/.env.prod" + +cd "$ROOT_DIR" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing ${ENV_FILE}. Copy .env.example to .env.prod and configure it." >&2 + exit 1 +fi + +# shellcheck disable=SC1090 +set -a +source "$ENV_FILE" +set +a + +if [[ -z "${CLEARNET_DOMAIN:-}" ]]; then + echo "CLEARNET_DOMAIN is not set in .env.prod" >&2 + exit 1 +fi + +LIVE_DIR="${ROOT_DIR}/deploy/certs/live/${CLEARNET_DOMAIN}" + +if [[ -f "${LIVE_DIR}/fullchain.pem" ]]; then + echo "Certificates already exist at deploy/certs/live/${CLEARNET_DOMAIN}" >&2 + exit 1 +fi + +mkdir -p "$LIVE_DIR" + +openssl req -x509 -nodes -newkey rsa:2048 -days 1 \ + -keyout "${LIVE_DIR}/privkey.pem" \ + -out "${LIVE_DIR}/fullchain.pem" \ + -subj "/CN=${CLEARNET_DOMAIN}" + +echo "Temporary self-signed certificates created at deploy/certs/live/${CLEARNET_DOMAIN}" diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh new file mode 100755 index 0000000..8e3b296 --- /dev/null +++ b/deploy/scripts/deploy.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ENV_FILE="${ROOT_DIR}/.env.prod" + +cd "$ROOT_DIR" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing ${ENV_FILE}. Copy .env.example to .env.prod and configure it." >&2 + exit 1 +fi + +docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml up -d --build + +echo "Pruning unused Docker data older than 24h..." +docker system prune -af --filter "until=24h" diff --git a/deploy/scripts/issue-certs.sh b/deploy/scripts/issue-certs.sh new file mode 100755 index 0000000..d672ee5 --- /dev/null +++ b/deploy/scripts/issue-certs.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ENV_FILE="${ROOT_DIR}/.env.prod" +CERTBOT_EMAIL="" + +usage() { + cat <&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing ${ENV_FILE}" >&2 + exit 1 +fi + +# shellcheck disable=SC1090 +set -a +source "$ENV_FILE" +set +a + +if [[ -z "${CLEARNET_DOMAIN:-}" ]]; then + echo "CLEARNET_DOMAIN is not set in .env.prod" >&2 + exit 1 +fi + +if [[ -z "$CERTBOT_EMAIL" ]]; then + echo "Pass --email for Let's Encrypt registration." >&2 + usage >&2 + exit 1 +fi + +mkdir -p "${ROOT_DIR}/deploy/certbot/www" "${ROOT_DIR}/deploy/certs" + +docker run --rm \ + -v "${ROOT_DIR}/deploy/certbot/www:/var/www/certbot" \ + -v "${ROOT_DIR}/deploy/certs:/etc/letsencrypt" \ + certbot/certbot certonly \ + --webroot \ + -w /var/www/certbot \ + -d "$CLEARNET_DOMAIN" \ + --email "$CERTBOT_EMAIL" \ + --agree-tos \ + --non-interactive + +if [[ ! -f "${ROOT_DIR}/deploy/certs/live/${CLEARNET_DOMAIN}/fullchain.pem" ]]; then + echo "Expected certificates at deploy/certs/live/${CLEARNET_DOMAIN}" >&2 + exit 1 +fi + +echo "Certificates issued at deploy/certs/live/${CLEARNET_DOMAIN}" +echo "Reload nginx: docker compose --env-file .env.prod -f docker-compose.prod.yml exec nginx nginx -s reload" diff --git a/deploy/scripts/renew-certs.sh b/deploy/scripts/renew-certs.sh new file mode 100755 index 0000000..28dc7c6 --- /dev/null +++ b/deploy/scripts/renew-certs.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ENV_FILE="${ROOT_DIR}/.env.prod" + +cd "$ROOT_DIR" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing ${ENV_FILE}" >&2 + exit 1 +fi + +mkdir -p "${ROOT_DIR}/deploy/certbot/www" "${ROOT_DIR}/deploy/certs" + +docker run --rm \ + -v "${ROOT_DIR}/deploy/certbot/www:/var/www/certbot" \ + -v "${ROOT_DIR}/deploy/certs:/etc/letsencrypt" \ + certbot/certbot renew \ + --webroot \ + -w /var/www/certbot + +docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec nginx nginx -s reload + +echo "Certificate renewal complete; nginx reloaded." diff --git a/deploy/scripts/show-onion.sh b/deploy/scripts/show-onion.sh new file mode 100755 index 0000000..2021287 --- /dev/null +++ b/deploy/scripts/show-onion.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ENV_FILE="${ROOT_DIR}/.env.prod" + +cd "$ROOT_DIR" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing ${ENV_FILE}" >&2 + exit 1 +fi + +docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec tor \ + cat /var/lib/tor/hs/hostname diff --git a/deploy/scripts/update.sh b/deploy/scripts/update.sh new file mode 100755 index 0000000..1d5c881 --- /dev/null +++ b/deploy/scripts/update.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +cd "$ROOT_DIR" + +git pull + +"${ROOT_DIR}/deploy/scripts/deploy.sh" diff --git a/deploy/tor/Dockerfile b/deploy/tor/Dockerfile new file mode 100644 index 0000000..41914c9 --- /dev/null +++ b/deploy/tor/Dockerfile @@ -0,0 +1,7 @@ +FROM alpine:3.20 + +RUN apk add --no-cache tor + +COPY torrc /etc/tor/torrc + +CMD ["tor", "-f", "/etc/tor/torrc"] diff --git a/deploy/tor/torrc b/deploy/tor/torrc new file mode 100644 index 0000000..90fb64b --- /dev/null +++ b/deploy/tor/torrc @@ -0,0 +1,5 @@ +SocksPort 0 +Log notice stdout + +HiddenServiceDir /var/lib/tor/hs/ +HiddenServicePort 80 nginx:8080 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..3133756 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,121 @@ +services: + postgres: + image: postgres:16 + container_name: ${COMPOSE_PROJECT_NAME}_postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - nullcart_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}'] + interval: 5s + timeout: 5s + retries: 10 + + pgadmin: + image: dpage/pgadmin4 + container_name: ${COMPOSE_PROJECT_NAME}_pgadmin + restart: unless-stopped + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD} + ports: + - '${PGADMIN_PORT}:80' + volumes: + - nullcart_pgadmin_data:/var/lib/pgadmin + depends_on: + postgres: + condition: service_healthy + + monero-wallet-rpc: + build: + context: ./monero-wallet-rpc + args: + MONERO_VERSION: ${MONERO_VERSION} + + container_name: ${COMPOSE_PROJECT_NAME}_monero_wallet_rpc + restart: unless-stopped + env_file: + - .env.dev + volumes: + - ${MONERO_WALLET_DIR}:/monero/wallet + healthcheck: + test: + [ + 'CMD-SHELL', + 'curl -sf --digest -u "$$MONERO_WALLET_RPC_USERNAME:$$MONERO_WALLET_RPC_PASSWORD" -H ''Content-Type: application/json'' -d ''{"method":"get_version"}'' http://127.0.0.1:$$MONERO_WALLET_RPC_PORT/json_rpc' + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 60s + + simplex-cli: + build: + context: ./simplex-cli + args: + SIMPLEX_CHAT_VERSION: ${SIMPLEX_CHAT_VERSION} + container_name: ${COMPOSE_PROJECT_NAME}_simplex_cli + restart: unless-stopped + environment: + SIMPLEX_BOT_DISPLAY_NAME: ${SIMPLEX_BOT_DISPLAY_NAME} + SIMPLEX_BOT_DESCRIPTION: ${SIMPLEX_BOT_DESCRIPTION} + volumes: + - nullcart_simplex_data:/simplex/data + healthcheck: + test: ['CMD-SHELL', 'bash -c "echo > /dev/tcp/127.0.0.1/5226"'] + interval: 10s + timeout: 5s + retries: 10 + start_period: 90s + + backend: + build: + context: ./backend + dockerfile: Dockerfile.dev + container_name: ${COMPOSE_PROJECT_NAME}_backend + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + monero-wallet-rpc: + condition: service_healthy + simplex-cli: + condition: service_healthy + env_file: + - .env.dev + ports: + - '${BACKEND_PORT}:${BACKEND_PORT}' + volumes: + - ./backend/src:/backend/src + - ./backend/uploads:/backend/uploads + healthcheck: + test: ['CMD-SHELL', 'curl -f http://backend:${BACKEND_PORT}/api/health-check/'] + interval: 1s + timeout: 30s + retries: 20 + start_period: 10s + + cms: + build: + context: ./cms + dockerfile: Dockerfile.dev + restart: unless-stopped + container_name: ${COMPOSE_PROJECT_NAME}_cms + env_file: + - .env.dev + ports: + - '${CMS_PORT}:5173' + volumes: + - ./cms/src:/cms/src + depends_on: + backend: + condition: service_healthy + +volumes: + nullcart_postgres_data: + nullcart_pgadmin_data: + nullcart_simplex_data: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..f06b87f --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,151 @@ +services: + postgres: + image: postgres:16 + container_name: ${COMPOSE_PROJECT_NAME}_postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - nullcart.postgres.prod.data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}'] + interval: 5s + timeout: 5s + retries: 10 + + pgadmin: + image: dpage/pgadmin4 + container_name: ${COMPOSE_PROJECT_NAME}_pgadmin + restart: unless-stopped + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD} + volumes: + - nullcart.pgadmin.prod.data:/var/lib/pgadmin + depends_on: + postgres: + condition: service_healthy + + monero-wallet-rpc: + build: + context: ./monero-wallet-rpc + args: + MONERO_VERSION: ${MONERO_VERSION} + container_name: ${COMPOSE_PROJECT_NAME}_monero_wallet_rpc + restart: unless-stopped + env_file: + - .env.prod + volumes: + - ${MONERO_WALLET_DIR}:/monero/wallet + healthcheck: + test: + [ + 'CMD-SHELL', + 'curl -sf --digest -u "$$MONERO_WALLET_RPC_USERNAME:$$MONERO_WALLET_RPC_PASSWORD" -H ''Content-Type: application/json'' -d ''{"method":"get_version"}'' http://127.0.0.1:$$MONERO_WALLET_RPC_PORT/json_rpc' + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 60s + + simplex-cli: + build: + context: ./simplex-cli + args: + SIMPLEX_CHAT_VERSION: ${SIMPLEX_CHAT_VERSION} + container_name: ${COMPOSE_PROJECT_NAME}_simplex_cli + restart: unless-stopped + environment: + SIMPLEX_BOT_DISPLAY_NAME: ${SIMPLEX_BOT_DISPLAY_NAME} + SIMPLEX_BOT_DESCRIPTION: ${SIMPLEX_BOT_DESCRIPTION} + volumes: + - nullcart.simplex.prod.data:/simplex/data + healthcheck: + test: ['CMD-SHELL', 'bash -c "echo > /dev/tcp/127.0.0.1/5226"'] + interval: 10s + timeout: 5s + retries: 10 + start_period: 90s + + backend: + build: + context: ./backend + dockerfile: Dockerfile.prod + container_name: ${COMPOSE_PROJECT_NAME}_backend + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + monero-wallet-rpc: + condition: service_healthy + simplex-cli: + condition: service_healthy + env_file: + - .env.prod + volumes: + - ./backend/uploads:/backend/uploads + healthcheck: + test: ['CMD-SHELL', 'curl -f http://backend:${BACKEND_PORT}/api/health-check/'] + interval: 10s + timeout: 30s + retries: 10 + start_period: 30s + + nginx: + build: + context: . + dockerfile: nginx/Dockerfile.prod + args: + VITE_API_BASE_URL: ${VITE_API_BASE_URL} + VITE_SHOP_FIAT_CURRENCY: ${VITE_SHOP_FIAT_CURRENCY} + VITE_PRODUCT_THUMB_ALLOWED_MIMES: ${VITE_PRODUCT_THUMB_ALLOWED_MIMES} + VITE_PRODUCT_THUMB_MAX_FILE_BYTES: ${VITE_PRODUCT_THUMB_MAX_FILE_BYTES} + VITE_SHOP_LOGO_ALLOWED_MIMES: ${VITE_SHOP_LOGO_ALLOWED_MIMES} + VITE_SHOP_LOGO_MAX_FILE_BYTES: ${VITE_SHOP_LOGO_MAX_FILE_BYTES} + VITE_SHOP_FAVICON_ALLOWED_MIMES: ${VITE_SHOP_FAVICON_ALLOWED_MIMES} + VITE_SHOP_FAVICON_MAX_FILE_BYTES: ${VITE_SHOP_FAVICON_MAX_FILE_BYTES} + VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES: ${VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES} + VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES: ${VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES} + VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH: ${VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH} + VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH: ${VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH} + VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH: ${VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH} + VITE_VALIDATION_VARIANT_IMAGES_MAX: ${VITE_VALIDATION_VARIANT_IMAGES_MAX} + VITE_VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX: ${VITE_VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX} + VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH: ${VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH} + VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH: ${VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH} + VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH: ${VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH} + VITE_ORDERS_DETAIL_POLL_INTERVAL_MS: ${VITE_ORDERS_DETAIL_POLL_INTERVAL_MS} + container_name: ${COMPOSE_PROJECT_NAME}_nginx + restart: unless-stopped + env_file: + - .env.prod + ports: + - '80:80' + - '443:443' + volumes: + - ./deploy/certs:/etc/nginx/certs:ro + - ./deploy/certbot/www:/var/www/certbot:ro + depends_on: + backend: + condition: service_healthy + pgadmin: + condition: service_started + + tor: + build: + context: ./deploy/tor + container_name: ${COMPOSE_PROJECT_NAME}_tor + restart: unless-stopped + volumes: + - nullcart.tor.prod.data:/var/lib/tor + depends_on: + nginx: + condition: service_started + +volumes: + nullcart.postgres.prod.data: + nullcart.pgadmin.prod.data: + nullcart.simplex.prod.data: + nullcart.tor.prod.data: diff --git a/monero-wallet-rpc/Dockerfile b/monero-wallet-rpc/Dockerfile new file mode 100644 index 0000000..0884075 --- /dev/null +++ b/monero-wallet-rpc/Dockerfile @@ -0,0 +1,38 @@ +FROM debian:bookworm-slim AS build + +ARG MONERO_VERSION +ARG TARGETARCH + +RUN apt-get update \ + && apt-get install -y --no-install-recommends bzip2 ca-certificates curl tar \ + && case "$TARGETARCH" in \ + amd64) monero_arch=x64 ;; \ + arm64) monero_arch=armv8 ;; \ + *) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && curl -fsSL "https://downloads.getmonero.org/cli/monero-linux-${monero_arch}-v${MONERO_VERSION}.tar.bz2" \ + | tar -xj -C /tmp \ + && install -m 755 "$(find /tmp -type f -name monero-wallet-rpc | head -n 1)" /monero-wallet-rpc \ + && rm -rf /var/lib/apt/lists/* + +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /monero-wallet-rpc /usr/local/bin/monero-wallet-rpc + +CMD ["/bin/sh", "-ec", "\ + exec monero-wallet-rpc \ + \"--${MONERO_NETWORK}\" \ + --daemon-address=\"${MONERO_DAEMON_ADDRESS}\" \ + --trusted-daemon \ + --no-initial-sync \ + --rpc-bind-ip=0.0.0.0 \ + --rpc-bind-port=${MONERO_WALLET_RPC_PORT} \ + --confirm-external-bind \ + --rpc-login=\"${MONERO_WALLET_RPC_USERNAME}:${MONERO_WALLET_RPC_PASSWORD}\" \ + --wallet-file=\"/monero/wallet/${MONERO_WALLET_NAME}\" \ + --password=\"${MONERO_WALLET_PASSWORD}\" \ +"] diff --git a/monero-wallet-rpc/setup-monero-wallet.sh b/monero-wallet-rpc/setup-monero-wallet.sh new file mode 100755 index 0000000..0b44e0a --- /dev/null +++ b/monero-wallet-rpc/setup-monero-wallet.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="" +MONERO_CLI_INSTALL_PATH="/usr/local/bin/monero-wallet-cli" + +usage() { + cat <&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "$ENV_FILE" ]]; then + echo "--env-file is required." >&2 + usage >&2 + exit 1 +fi + +if [[ "$ENV_FILE" != /* ]]; then + ENV_FILE="${ROOT_DIR}/${ENV_FILE#./}" +fi + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Env file not found: $ENV_FILE" >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +main() { + validate_env + ensure_dependencies + setup_wallet_config + create_wallet_if_missing +} + +validate_env() { + local missing=() + + for var in MONERO_VERSION MONERO_WALLET_DIR MONERO_WALLET_NAME MONERO_NETWORK MONERO_WALLET_PASSWORD; do + if [[ -z "${!var:-}" ]]; then + missing+=("$var") + fi + done + + if [[ ${#missing[@]} -gt 0 ]]; then + echo "Missing required env vars in ${ENV_FILE}: ${missing[*]}" >&2 + exit 1 + fi + + case "$MONERO_NETWORK" in + mainnet | stagenet | testnet) ;; + *) + echo "MONERO_NETWORK must be mainnet, stagenet, or testnet (got: ${MONERO_NETWORK})." >&2 + exit 1 + ;; + esac +} + +ensure_dependencies() { + if ! command -v apt-get >/dev/null 2>&1; then + echo "apt-get is required. This script supports Ubuntu/Debian only." >&2 + exit 1 + fi + + local missing=() + + for cmd in curl tar bzip2; do + if command -v "$cmd" >/dev/null 2>&1; then + log_skip "$cmd" + else + log_install "$cmd" + missing+=("$cmd") + fi + done + + if [[ ${#missing[@]} -gt 0 ]]; then + run_privileged apt-get update -qq + run_privileged apt-get install -y -qq "${missing[@]}" + fi + + install_monero_wallet_cli_if_missing +} + +install_monero_wallet_cli_if_missing() { + local arch archive_url tmp_dir extracted_cli + + if command -v monero-wallet-cli >/dev/null 2>&1; then + log_skip "monero-wallet-cli" + return + fi + + log_install "monero-wallet-cli" + + arch="$(uname -m)" + + case "$arch" in + x86_64 | amd64) + archive_url="https://downloads.getmonero.org/cli/monero-linux-x64-v${MONERO_VERSION}.tar.bz2" + ;; + aarch64 | arm64) + archive_url="https://downloads.getmonero.org/cli/monero-linux-armv8-v${MONERO_VERSION}.tar.bz2" + ;; + *) + echo "Unsupported CPU architecture: ${arch}" >&2 + echo "Install monero-wallet-cli from https://www.getmonero.org/downloads/ and re-run." >&2 + exit 1 + ;; + esac + + tmp_dir="$(mktemp -d)" + trap "rm -rf '${tmp_dir}'" RETURN + + curl -fsSL "$archive_url" | tar -xj -C "$tmp_dir" + extracted_cli="$(find "$tmp_dir" -type f -name monero-wallet-cli | head -n 1)" + + if [[ -z "$extracted_cli" ]]; then + echo "Could not find monero-wallet-cli in the downloaded archive." >&2 + exit 1 + fi + + run_privileged install -m 755 "$extracted_cli" "$MONERO_CLI_INSTALL_PATH" +} + +setup_wallet_config() { + WALLET_DIR="$(resolve_path "$MONERO_WALLET_DIR")" + WALLET_PATH="${WALLET_DIR}/${MONERO_WALLET_NAME}" + NETWORK="$MONERO_NETWORK" +} + +resolve_path() { + local path="$1" + + if [[ "$path" != /* ]]; then + path="${ROOT_DIR}/${path#./}" + fi + + printf '%s' "$path" +} + +create_wallet_if_missing() { + mkdir -p "$WALLET_DIR" + chmod 700 "$WALLET_DIR" + + if [[ -f "$WALLET_PATH" || -f "${WALLET_PATH}.keys" ]]; then + echo "Wallet already exists at ${WALLET_PATH} — skipping creation." + return + fi + + echo "Creating ${NETWORK} wallet at ${WALLET_PATH}..." + + monero-wallet-cli "--${NETWORK}" \ + --offline \ + --log-file /dev/null \ + --generate-new-wallet "$WALLET_PATH" \ + --password "$MONERO_WALLET_PASSWORD" \ + --mnemonic-language English \ + --command save + + chmod 600 "${WALLET_PATH}" "${WALLET_PATH}.keys" 2>/dev/null || true + + cat </dev/null 2>&1; then + sudo "$@" + else + echo "Root or sudo is required to install missing packages." >&2 + exit 1 + fi +} + +log_skip() { + echo "Package ${1} already installed — skipping." +} + +log_install() { + echo "Package ${1} is not installed — installing..." +} + +main diff --git a/nginx/Dockerfile.prod b/nginx/Dockerfile.prod new file mode 100644 index 0000000..cdae92e --- /dev/null +++ b/nginx/Dockerfile.prod @@ -0,0 +1,64 @@ +FROM node:20-alpine AS cms-builder + +WORKDIR /cms + +COPY cms/package*.json . +RUN npm ci + +COPY cms/ . + +ARG VITE_API_BASE_URL +ARG VITE_SHOP_FIAT_CURRENCY +ARG VITE_PRODUCT_THUMB_ALLOWED_MIMES +ARG VITE_PRODUCT_THUMB_MAX_FILE_BYTES +ARG VITE_SHOP_LOGO_ALLOWED_MIMES +ARG VITE_SHOP_LOGO_MAX_FILE_BYTES +ARG VITE_SHOP_FAVICON_ALLOWED_MIMES +ARG VITE_SHOP_FAVICON_MAX_FILE_BYTES +ARG VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES +ARG VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES +ARG VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH +ARG VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH +ARG VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH +ARG VITE_VALIDATION_VARIANT_IMAGES_MAX +ARG VITE_VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX +ARG VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH +ARG VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH +ARG VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH +ARG VITE_ORDERS_DETAIL_POLL_INTERVAL_MS + +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \ + VITE_SHOP_FIAT_CURRENCY=$VITE_SHOP_FIAT_CURRENCY \ + VITE_PRODUCT_THUMB_ALLOWED_MIMES=$VITE_PRODUCT_THUMB_ALLOWED_MIMES \ + VITE_PRODUCT_THUMB_MAX_FILE_BYTES=$VITE_PRODUCT_THUMB_MAX_FILE_BYTES \ + VITE_SHOP_LOGO_ALLOWED_MIMES=$VITE_SHOP_LOGO_ALLOWED_MIMES \ + VITE_SHOP_LOGO_MAX_FILE_BYTES=$VITE_SHOP_LOGO_MAX_FILE_BYTES \ + VITE_SHOP_FAVICON_ALLOWED_MIMES=$VITE_SHOP_FAVICON_ALLOWED_MIMES \ + VITE_SHOP_FAVICON_MAX_FILE_BYTES=$VITE_SHOP_FAVICON_MAX_FILE_BYTES \ + VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES=$VITE_DIGITAL_STOCK_ATTACHMENT_ALLOWED_MIMES \ + VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES=$VITE_DIGITAL_STOCK_ATTACHMENT_MAX_FILE_BYTES \ + VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH=$VITE_VALIDATION_PRODUCT_TITLE_MAX_LENGTH \ + VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH=$VITE_VALIDATION_CATEGORY_NAME_MAX_LENGTH \ + VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH=$VITE_VALIDATION_DISCOUNT_CODE_MAX_LENGTH \ + VITE_VALIDATION_VARIANT_IMAGES_MAX=$VITE_VALIDATION_VARIANT_IMAGES_MAX \ + VITE_VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX=$VITE_VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX \ + VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH=$VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH \ + VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH=$VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH \ + VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH=$VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH \ + VITE_ORDERS_DETAIL_POLL_INTERVAL_MS=$VITE_ORDERS_DETAIL_POLL_INTERVAL_MS + +RUN npm run build + +FROM nginx:alpine + +RUN apk add --no-cache gettext + +COPY --from=cms-builder /cms/dist /usr/share/nginx/html/cms + +COPY nginx/conf.d/ /etc/nginx/templates/conf.d/ +COPY nginx/snippets/ /etc/nginx/templates/snippets/ +COPY nginx/docker-entrypoint.sh /docker-entrypoint.sh + +RUN chmod +x /docker-entrypoint.sh + +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/nginx/conf.d/clearnet.conf.template b/nginx/conf.d/clearnet.conf.template new file mode 100644 index 0000000..f218c38 --- /dev/null +++ b/nginx/conf.d/clearnet.conf.template @@ -0,0 +1,22 @@ +server { + listen 80; + server_name ${CLEARNET_DOMAIN}; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + server_name ${CLEARNET_DOMAIN}; + + ssl_certificate /etc/nginx/certs/live/${CLEARNET_DOMAIN}/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/live/${CLEARNET_DOMAIN}/privkey.pem; + + include /etc/nginx/snippets/nullcart-locations-${SHOP_SURFACE}.conf; +} diff --git a/nginx/conf.d/onion.conf.template b/nginx/conf.d/onion.conf.template new file mode 100644 index 0000000..fd26952 --- /dev/null +++ b/nginx/conf.d/onion.conf.template @@ -0,0 +1,6 @@ +server { + listen 8080; + server_name _; + + include /etc/nginx/snippets/nullcart-locations-${SHOP_SURFACE}.conf; +} diff --git a/nginx/docker-entrypoint.sh b/nginx/docker-entrypoint.sh new file mode 100755 index 0000000..3308e23 --- /dev/null +++ b/nginx/docker-entrypoint.sh @@ -0,0 +1,31 @@ +#!/bin/sh +set -eu + +render_locations() { + surface="$1" + SHOP_SURFACE="$surface" + export SHOP_SURFACE BACKEND_PORT + envsubst '${SHOP_SURFACE} ${BACKEND_PORT}' \ + < /etc/nginx/templates/snippets/nullcart-locations.conf.template \ + > "/etc/nginx/snippets/nullcart-locations-${surface}.conf" +} + +render_server() { + template_path="$1" + output_path="$2" + surface="$3" + SHOP_SURFACE="$surface" + export SHOP_SURFACE CLEARNET_DOMAIN BACKEND_PORT + envsubst '${CLEARNET_DOMAIN} ${BACKEND_PORT} ${SHOP_SURFACE}' \ + < "$template_path" \ + > "$output_path" +} + +mkdir -p /etc/nginx/snippets + +render_locations clearnet +render_locations onion +render_server /etc/nginx/templates/conf.d/clearnet.conf.template /etc/nginx/conf.d/clearnet.conf clearnet +render_server /etc/nginx/templates/conf.d/onion.conf.template /etc/nginx/conf.d/onion.conf onion + +exec nginx -g 'daemon off;' diff --git a/nginx/snippets/nullcart-locations.conf.template b/nginx/snippets/nullcart-locations.conf.template new file mode 100644 index 0000000..908a686 --- /dev/null +++ b/nginx/snippets/nullcart-locations.conf.template @@ -0,0 +1,59 @@ +location /uploads/public/ { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header x-shop-surface ${SHOP_SURFACE}; + proxy_pass http://backend:${BACKEND_PORT}; +} + +location = /api/shop-settings/simplex-connect { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header x-shop-surface ${SHOP_SURFACE}; + proxy_read_timeout 320s; + proxy_send_timeout 320s; + proxy_connect_timeout 30s; + proxy_pass http://backend:${BACKEND_PORT}; +} + +location /api { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header x-shop-surface ${SHOP_SURFACE}; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + proxy_pass http://backend:${BACKEND_PORT}; +} + +location = /cms { + return 301 /cms/; +} + +location /cms/ { + root /usr/share/nginx/html; + try_files $uri $uri/ /cms/index.html; +} + +location /pgadmin { + proxy_set_header X-Script-Name /pgadmin; + proxy_set_header Host $host; + proxy_set_header X-Scheme $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass_header Set-Cookie; + proxy_pass http://pgadmin; + proxy_redirect off; +} + +location / { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header x-shop-surface ${SHOP_SURFACE}; + proxy_pass http://backend:${BACKEND_PORT}; +} diff --git a/simplex-cli/Dockerfile b/simplex-cli/Dockerfile new file mode 100644 index 0000000..291e5e7 --- /dev/null +++ b/simplex-cli/Dockerfile @@ -0,0 +1,28 @@ +FROM ubuntu:22.04 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + libgmp10 \ + socat \ + && rm -rf /var/lib/apt/lists/* + +ARG SIMPLEX_CHAT_VERSION +ARG TARGETARCH + +RUN case "$TARGETARCH" in \ + amd64) simplex_arch=x86_64 ;; \ + arm64) simplex_arch=aarch64 ;; \ + *) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && curl -fsSL \ + "https://github.com/simplex-chat/simplex-chat/releases/download/${SIMPLEX_CHAT_VERSION}/simplex-chat-ubuntu-22_04-${simplex_arch}" \ + -o /tmp/simplex-chat \ + && install -m 755 /tmp/simplex-chat /usr/local/bin/simplex-chat \ + && rm /tmp/simplex-chat + +COPY --chmod=755 entrypoint.sh /usr/local/bin/entrypoint.sh +COPY bot_avatar.jpeg /simplex/bot_avatar.jpeg + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/simplex-cli/bot_avatar.jpeg b/simplex-cli/bot_avatar.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..66c67b4449ee6b8d24994c1c1b6b3307c950f3f6 GIT binary patch literal 1765 zcmbu9dsxzE8^@m?s4z1H6oVT```P=d*9Dr&vo6;{dvCE_5G@U zRzC))U48+60D=I3G!4{K;A^0}R_pZi))}DmQ3l3FhK5GQL>ykzL434OVE*uxj1F#Ig7#H zIn~0#jQT2LUa$x#4E|<2oAxK2qdJvCXr?%&_ZLhjl58X1PjK&^imyrs$)o6E0$qhuyFdx6{L$$FSCzynft6gC8cP-m1hQ zZDDaQ!6>zTYujIEpS@X46)=gOzBtAL4|bV(ZHM)l%_4)K27zoj;QsYc=E+_dvVSFl zbgG^t)lQY%8apXJlH2%lyh22gy4l`Wa%l)9yqIMw97Rr>50wwC_|X!Z##O67M7Aqd zDtQ+9Et~IrId4|ICs~@2FOR<)<8>~3?@yA56uBB?#Sv7xAteUBZ_9;Sj!B;PHON%- zV&rHhd8EiWp_F-=`hwEMU>xuz6_#~enGG|$*Q#>O)#Ic;*BNE)iRO21e}ivZZV+Az zBPlwg{`RYBaO-u#*+fZ>&3coKx+^Tqh4RIe~y7Um>K{@BQ3@8WXwr?nr z!Jp@2eVpue4j--0#_>}GcB(x62k8Z7l~{4&@i1PIH#e*zY41}WMu5<&Drs=TmAmm% zRe5$qh2F_8ax%nM4>OO&UDAqG;WgA%$cHmEeQL08b)q)Evm^2_zR=2W@adV=Df;aL zU%hO|2@G|gKk{q-jgDWKPtAsa$I~J!x(wD62WCpj-O2~9Da6_>I!~;WPFyB zA2PLjR)(iZSTJv*lSTHKLq7L>Zsif*#lH33 z`DfN-vIx8#`%V5t;+p$g)nf#Tj&6$VR|VjS?T56}-0YNa^lC4OH7dI33c1ztM6kTqQheW=8IH-V%L2 zgpMB1wNqYCFkbZP?oxxT$vs^)0)hE+_P}Hh|AGMhO7$Wl+{-9Ic@$#?FBpGt{g=?h z5wuVI<{}WB3BTg!g-s-U;r(t7N36cipKj$X)uX1%x;E;ri_@to3^27$D}7l%;*Z4eJ}VIpz`O@v3u1K{Tm)Uqo<)jm3v)|fI-5S>@yRP)MRShx z3+!-oR@@x9vh(l0{xG+UrQ(w2lZx3Yi>Q_1K6mdHI-40G4SXG_akv#)<8YW3;-6f3 z?{KshieO<#w0C5D*^2oPFp$Q}Lnq3Se7}!H+Xuhl#WPZp8NYndAir D-3iPf literal 0 HcmV?d00001 diff --git a/simplex-cli/entrypoint.sh b/simplex-cli/entrypoint.sh new file mode 100755 index 0000000..cb363e1 --- /dev/null +++ b/simplex-cli/entrypoint.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -e + +DB_DIR="/simplex/data" +DB_PREFIX="simplex" +BOT_NAME="${SIMPLEX_BOT_DISPLAY_NAME}" +BOT_DESCRIPTION="${SIMPLEX_BOT_DESCRIPTION}" +BOT_AVATAR="/simplex/bot_avatar.jpeg" +INTERNAL_PORT=5226 +EXTERNAL_PORT=5225 + +has_chat_db() { + find "$DB_DIR" -maxdepth 1 -type f -name '*.db' 2>/dev/null | grep -q . +} + +mkdir -p "$DB_DIR" +cd "$DB_DIR" + +echo "Starting socat proxy (0.0.0.0:$EXTERNAL_PORT -> 127.0.0.1:$INTERNAL_PORT)..." +socat TCP-LISTEN:$EXTERNAL_PORT,fork,reuseaddr TCP:127.0.0.1:$INTERNAL_PORT & + +DB_ARGS=(-d "$DB_PREFIX") +ARGS=("${DB_ARGS[@]}" -p "$INTERNAL_PORT") + +if ! has_chat_db; then + echo "First run detected. Creating bot profile: $BOT_NAME" + AVATAR_DATA="$(base64 -w 0 "$BOT_AVATAR")" + + simplex-chat \ + --create-bot-display-name "$BOT_NAME" \ + "${DB_ARGS[@]}" \ + -e "/_profile 1 {\"displayName\":\"$BOT_NAME\",\"fullName\":\"$BOT_NAME\",\"shortDescr\":\"$BOT_DESCRIPTION\",\"image\":\"$AVATAR_DATA\"}" +fi + +echo "Starting SimpleX CLI in API mode on internal port $INTERNAL_PORT..." +exec simplex-chat "${ARGS[@]}"