diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index ff8cb552..dddea5b6 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -62,6 +62,12 @@ components: $ref: paths/customer/product/destroy/response.yaml CustomerProductGetResponse: $ref: paths/customer/product/get/response.yaml + CustomerProductCreateVariantResponse: + $ref: paths/customer/product/create-variant/response.yaml + CustomerProductCreateVariantBody: + $ref: paths/customer/product/create-variant/body.yaml + CustomerProductCreateVariant: + $ref: paths/customer/product/create-variant/variant.yaml # Schedule CustomerScheduleDestroy: @@ -351,6 +357,8 @@ paths: $ref: "./paths/customer/product/list-ids/index.yaml" /customer/{customerId}/product/{productId}: $ref: "paths/customer/product/product.yaml" + /customer/{customerId}/product/{productId}/create-variant: + $ref: "paths/customer/product/create-variant/index.yaml" # Orders /customer/{customerId}/bookings/{orderId}/group/{groupId}: diff --git a/openapi/paths/customer/product/create-variant/body.yaml b/openapi/paths/customer/product/create-variant/body.yaml new file mode 100644 index 00000000..1869d600 --- /dev/null +++ b/openapi/paths/customer/product/create-variant/body.yaml @@ -0,0 +1,10 @@ +type: object +properties: + price: + type: string + compareAtPrice: + type: string + +required: + - price + - compareAtPrice diff --git a/openapi/paths/customer/product/create-variant/index.yaml b/openapi/paths/customer/product/create-variant/index.yaml new file mode 100644 index 00000000..2c419ca5 --- /dev/null +++ b/openapi/paths/customer/product/create-variant/index.yaml @@ -0,0 +1,41 @@ +post: + parameters: + - name: customerId + in: path + required: true + schema: + type: string + - name: productId + in: path + required: true + schema: + type: string + tags: + - CustomerProduct + operationId: customerProductCreateVariant + summary: POST create product variant + description: This endpoint create product variant + requestBody: + required: true + content: + application/json: + schema: + $ref: "./body.yaml" + + responses: + "200": + description: Response with product variant payload + content: + application/json: + schema: + $ref: "./response.yaml" + "400": + $ref: "../../../../responses/bad.yaml" + "401": + $ref: "../../../../responses/unauthorized.yaml" + "403": + $ref: "../../../../responses/forbidden.yaml" + "404": + $ref: "../../../../responses/not-found.yaml" + + security: [] diff --git a/openapi/paths/customer/product/create-variant/response.yaml b/openapi/paths/customer/product/create-variant/response.yaml new file mode 100644 index 00000000..bf86ee09 --- /dev/null +++ b/openapi/paths/customer/product/create-variant/response.yaml @@ -0,0 +1,10 @@ +type: object +properties: + success: + type: boolean + example: true + payload: + $ref: ./variant.yaml +required: + - success + - payload diff --git a/openapi/paths/customer/product/create-variant/variant.yaml b/openapi/paths/customer/product/create-variant/variant.yaml new file mode 100644 index 00000000..419be7e0 --- /dev/null +++ b/openapi/paths/customer/product/create-variant/variant.yaml @@ -0,0 +1,10 @@ +type: object +properties: + id: + type: number + title: + type: string + selectedOptions: + type: array + items: + $ref: ../../schedule/_types/product-selected-options.yaml diff --git a/package-lock.json b/package-lock.json index 57427eeb..5ddad918 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,10 @@ "dependencies": { "@azure/functions": "^4.0.0", "@azure/storage-queue": "^12.16.0", - "@shopify/shopify-api": "^8.0.2", + "@shopify/admin-api-client": "^0.2.8", + "@shopify/shopify-api": "^9.5.0", "@types/jsonwebtoken": "^9.0.3", - "applicationinsights": "^2.9.1", + "applicationinsights": "^2.9.5", "axios": "^1.5.1", "bcryptjs": "^2.4.3", "date-fns": "^2.30.0", @@ -2468,15 +2469,6 @@ "node": ">= 14" } }, - "node_modules/@graphql-tools/prisma-loader/node_modules/jose": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.2.3.tgz", - "integrity": "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/@graphql-tools/prisma-loader/node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -3219,17 +3211,17 @@ } }, "node_modules/@opentelemetry/core": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.18.1.tgz", - "integrity": "sha512-kvnUqezHMhsQvdsnhnqTNfAJs3ox/isB0SVrM1dhVFw7SsB7TstuVa6fgWnN2GdPyilIFLUvvbTZoVRmx6eiRg==", + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.22.0.tgz", + "integrity": "sha512-0VoAlT6x+Xzik1v9goJ3pZ2ppi6+xd3aUfg4brfrLkDBHRIVjMP0eBHrKrhB+NKcDyMAg8fAbGL3Npg/F6AwWA==", "dependencies": { - "@opentelemetry/semantic-conventions": "1.18.1" + "@opentelemetry/semantic-conventions": "1.22.0" }, "engines": { "node": ">=14" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.8.0" + "@opentelemetry/api": ">=1.0.0 <1.9.0" } }, "node_modules/@opentelemetry/instrumentation": { @@ -3281,40 +3273,40 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/@opentelemetry/resources": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.18.1.tgz", - "integrity": "sha512-JjbcQLYMttXcIabflLRuaw5oof5gToYV9fuXbcsoOeQ0BlbwUn6DAZi++PNsSz2jjPeASfDls10iaO/8BRIPRA==", + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.22.0.tgz", + "integrity": "sha512-+vNeIFPH2hfcNL0AJk/ykJXoUCtR1YaDUZM+p3wZNU4Hq98gzq+7b43xbkXjadD9VhWIUQqEwXyY64q6msPj6A==", "dependencies": { - "@opentelemetry/core": "1.18.1", - "@opentelemetry/semantic-conventions": "1.18.1" + "@opentelemetry/core": "1.22.0", + "@opentelemetry/semantic-conventions": "1.22.0" }, "engines": { "node": ">=14" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.8.0" + "@opentelemetry/api": ">=1.0.0 <1.9.0" } }, "node_modules/@opentelemetry/sdk-trace-base": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.18.1.tgz", - "integrity": "sha512-tRHfDxN5dO+nop78EWJpzZwHsN1ewrZRVVwo03VJa3JQZxToRDH29/+MB24+yoa+IArerdr7INFJiX/iN4gjqg==", + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.22.0.tgz", + "integrity": "sha512-pfTuSIpCKONC6vkTpv6VmACxD+P1woZf4q0K46nSUvXFvOFqjBYKFaAMkKD3M1mlKUUh0Oajwj35qNjMl80m1Q==", "dependencies": { - "@opentelemetry/core": "1.18.1", - "@opentelemetry/resources": "1.18.1", - "@opentelemetry/semantic-conventions": "1.18.1" + "@opentelemetry/core": "1.22.0", + "@opentelemetry/resources": "1.22.0", + "@opentelemetry/semantic-conventions": "1.22.0" }, "engines": { "node": ">=14" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.8.0" + "@opentelemetry/api": ">=1.0.0 <1.9.0" } }, "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.18.1.tgz", - "integrity": "sha512-+NLGHr6VZwcgE/2lw8zDIufOCGnzsA5CbQIMleXZTrgkBd0TanCX+MiDYJ1TOS4KL/Tqk0nFRxawnaYr6pkZkA==", + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.22.0.tgz", + "integrity": "sha512-CAOgFOKLybd02uj/GhCdEeeBjOS0yeoDeo/CA7ASBSmenpZHAKGB3iDm/rv3BQLcabb/OprDEsSQ1y0P8A7Siw==", "engines": { "node": ">=14" } @@ -3803,6 +3795,14 @@ "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", "dev": true }, + "node_modules/@shopify/admin-api-client": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@shopify/admin-api-client/-/admin-api-client-0.2.8.tgz", + "integrity": "sha512-UyGhssHLw8Cwl7ai9SFtiZj0A78Ldt0qKotKg7r95mNTfKLao01oVjJFOZCZfFK+1x7ePUdh+UD+pbsb17KshA==", + "dependencies": { + "@shopify/graphql-client": "^0.10.3" + } + }, "node_modules/@shopify/api-codegen-preset": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/@shopify/api-codegen-preset/-/api-codegen-preset-0.0.5.tgz", @@ -3817,6 +3817,11 @@ "graphql": "^16.8.1" } }, + "node_modules/@shopify/graphql-client": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@shopify/graphql-client/-/graphql-client-0.10.3.tgz", + "integrity": "sha512-w9noa5wbuyQegOdmqR5Yfj0GQJKmwuDiqIzDETgZhTMcmV45CNxVRxCWx6BQt3MpLzgk0A8G9iaoppfHXzkhfA==" + }, "node_modules/@shopify/graphql-codegen": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/@shopify/graphql-codegen/-/graphql-codegen-0.0.1.tgz", @@ -3856,24 +3861,21 @@ } }, "node_modules/@shopify/shopify-api": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@shopify/shopify-api/-/shopify-api-8.0.2.tgz", - "integrity": "sha512-hvVLoEsYglE4GRqFhr9D6oMr2bV6tEdsD9PxuNZ6bYDptoD+kQFKsaP83jE1qtHhB3ve0DeevaVVYjS/2TU7MA==", + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/@shopify/shopify-api/-/shopify-api-9.5.0.tgz", + "integrity": "sha512-DqfkeHJn3uch3ujSzku9R2Q3rsCtW7QHbyPHIahORQsQVKj+YAhkDjw1V+pMQRTbveL2HHW6D2r8GQvEPOU2Gw==", "dependencies": { + "@shopify/admin-api-client": "^0.2.8", "@shopify/network": "^3.2.1", - "compare-versions": "^5.0.3", - "isbot": "^3.6.10", - "jose": "^4.9.1", + "@shopify/storefront-api-client": "^0.3.3", + "compare-versions": "^6.1.0", + "isbot": "^4.4.0", + "jose": "^5.2.2", "node-fetch": "^2.6.1", "tslib": "^2.0.3", "uuid": "^9.0.0" } }, - "node_modules/@shopify/shopify-api/node_modules/compare-versions": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-5.0.3.tgz", - "integrity": "sha512-4UZlZP8Z99MGEY+Ovg/uJxJuvoXuN4M6B3hKaiackiHrgzQFEe3diJi1mf1PNHbFujM7FvLrK2bpgIaImbtZ1A==" - }, "node_modules/@shopify/shopify-api/node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -3886,6 +3888,14 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/@shopify/storefront-api-client": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@shopify/storefront-api-client/-/storefront-api-client-0.3.3.tgz", + "integrity": "sha512-yoZul8r2mhb/XCDh55UxKNuo/PDB+bldTOt+rRz9uCKC9WkVQfGfMLwJhKb92i3CT/cxWNREPqEN5z1H+B9+IQ==", + "dependencies": { + "@shopify/graphql-client": "^0.10.3" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -4796,23 +4806,23 @@ } }, "node_modules/applicationinsights": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/applicationinsights/-/applicationinsights-2.9.1.tgz", - "integrity": "sha512-hrpe/OvHFZlq+SQERD1fxaYICyunxzEBh9SolJebzYnIXkyA9zxIR87dZAh+F3+weltbqdIP8W038cvtpMNhQg==", + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/applicationinsights/-/applicationinsights-2.9.5.tgz", + "integrity": "sha512-APQ8IWyYDHFvKbitFKpsmZXxkzQh0yYTFacQqoVW7HwlPo3eeLprwnq5RFNmmG6iqLmvQ+xRJSDLEQCgqPh+bw==", "dependencies": { "@azure/core-auth": "^1.5.0", "@azure/core-rest-pipeline": "1.10.1", "@azure/core-util": "1.2.0", "@azure/opentelemetry-instrumentation-azure-sdk": "^1.0.0-beta.5", - "@microsoft/applicationinsights-web-snippet": "^1.0.1", - "@opentelemetry/api": "^1.4.1", - "@opentelemetry/core": "^1.15.2", - "@opentelemetry/sdk-trace-base": "^1.15.2", - "@opentelemetry/semantic-conventions": "^1.15.2", + "@microsoft/applicationinsights-web-snippet": "1.0.1", + "@opentelemetry/api": "^1.7.0", + "@opentelemetry/core": "^1.19.0", + "@opentelemetry/sdk-trace-base": "^1.19.0", + "@opentelemetry/semantic-conventions": "^1.19.0", "cls-hooked": "^4.2.2", "continuation-local-storage": "^3.2.1", "diagnostic-channel": "1.1.1", - "diagnostic-channel-publishers": "1.0.7" + "diagnostic-channel-publishers": "1.0.8" }, "engines": { "node": ">=8.0.0" @@ -5770,8 +5780,7 @@ "node_modules/compare-versions": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.0.tgz", - "integrity": "sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg==", - "dev": true + "integrity": "sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg==" }, "node_modules/concat-map": { "version": "0.0.1", @@ -6095,9 +6104,9 @@ } }, "node_modules/diagnostic-channel-publishers": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/diagnostic-channel-publishers/-/diagnostic-channel-publishers-1.0.7.tgz", - "integrity": "sha512-SEECbY5AiVt6DfLkhkaHNeshg1CogdLLANA8xlG/TKvS+XUgvIKl7VspJGYiEdL5OUyzMVnr7o0AwB7f+/Mjtg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/diagnostic-channel-publishers/-/diagnostic-channel-publishers-1.0.8.tgz", + "integrity": "sha512-HmSm9hXxSPxA9BaLGY98QU1zsdjeCk113KjAYGPCen1ZP6mhVaTPzHd6UYv5r21DnWANi+f+NyPOHruGT9jpqQ==", "peerDependencies": { "diagnostic-channel": "*" } @@ -6114,9 +6123,9 @@ } }, "node_modules/diagnostic-channel/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", "dependencies": { "lru-cache": "^6.0.0" }, @@ -8446,11 +8455,11 @@ "dev": true }, "node_modules/isbot": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/isbot/-/isbot-3.7.0.tgz", - "integrity": "sha512-9BcjlI89966BqWJmYdTnRub85sit931MyCthSIPtgoOsTjoW7A2MVa09HzPpYE2+G4vyAxfDvR0AbUGV0FInQg==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-4.4.0.tgz", + "integrity": "sha512-8ZvOWUA68kyJO4hHJdWjyreq7TYNWTS9y15IzeqVdKxR9pPr3P/3r9AHcoIv9M0Rllkao5qWz2v1lmcyKIVCzQ==", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/isexe": { @@ -9206,9 +9215,9 @@ } }, "node_modules/jose": { - "version": "4.15.4", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.4.tgz", - "integrity": "sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.2.3.tgz", + "integrity": "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA==", "funding": { "url": "https://github.com/sponsors/panva" } diff --git a/package.json b/package.json index 4af6ce4a..130cb2db 100644 --- a/package.json +++ b/package.json @@ -19,14 +19,15 @@ "generate-docs": "npx @redocly/cli build-docs docs/openapi.yaml -o docs/index.html --title 'Booking Api Documentation'", "generate-ts": "npm run bundle && npx orval --config ./orval.config.js", "postprocess": "node postprocess.js", - "graphql:codegen": "npx graphql-codegen && npm run postprocess" + "graphql:codegen": "npx graphql-codegen -- --watch && npm run postprocess" }, "dependencies": { "@azure/functions": "^4.0.0", "@azure/storage-queue": "^12.16.0", - "@shopify/shopify-api": "^8.0.2", + "@shopify/admin-api-client": "^0.2.8", + "@shopify/shopify-api": "^9.5.0", "@types/jsonwebtoken": "^9.0.3", - "applicationinsights": "^2.9.1", + "applicationinsights": "^2.9.5", "axios": "^1.5.1", "bcryptjs": "^2.4.3", "date-fns": "^2.30.0", diff --git a/src/functions/customer-product.function.ts b/src/functions/customer-product.function.ts index a5f114de..4063e894 100644 --- a/src/functions/customer-product.function.ts +++ b/src/functions/customer-product.function.ts @@ -3,11 +3,10 @@ import { CustomerProductsControllerList } from "./customer/controllers/products/ import { app } from "@azure/functions"; -import { - CustomerProductControllerDestroy, - CustomerProductControllerGet, - CustomerProductControllerUpsert, -} from "./customer/controllers/product"; +import { CustomerProductControllerCreateVariant } from "./customer/controllers/product/create-variant"; +import { CustomerProductControllerDestroy } from "./customer/controllers/product/destroy"; +import { CustomerProductControllerGet } from "./customer/controllers/product/get"; +import { CustomerProductControllerUpsert } from "./customer/controllers/product/upsert"; import { CustomerProductsControllerListIds } from "./customer/controllers/products"; app.http("customerProductsListIds", { @@ -24,6 +23,13 @@ app.http("customerProductsList", { handler: CustomerProductsControllerList, }); +app.http("customerProductCreateVariant", { + methods: ["POST"], + authLevel: "anonymous", + route: "customer/{customerId?}/product/{productId?}/create-variant", + handler: CustomerProductControllerCreateVariant, +}); + app.http("customerProductUpsert", { methods: ["PUT"], authLevel: "anonymous", diff --git a/src/functions/customer-upload.orchestrator.ts b/src/functions/customer-upload.orchestrator.ts index 6d5e6bbf..73f96775 100644 --- a/src/functions/customer-upload.orchestrator.ts +++ b/src/functions/customer-upload.orchestrator.ts @@ -11,6 +11,7 @@ import { z } from "zod"; import { connect } from "~/library/mongoose"; import { shopifyAdmin } from "~/library/shopify"; import { NumberOrStringType } from "~/library/zod"; +import { type FileGetQuery } from "~/types/admin.generated"; import { CustomerUploadControllerResourceURL } from "./customer/controllers/upload/resource-url"; import { UserModel } from "./user"; @@ -41,7 +42,7 @@ df.app.orchestration("upload", function* (context: OrchestrationContext) { const maxRetries = 5; let attemptCount = 0; - let fileUploaded: PreviewImage | undefined; + let fileUploaded: FileGetQuery["files"]["nodes"][number] | undefined; while (!fileUploaded && attemptCount < maxRetries) { // Wait for 5 seconds before each new attempt @@ -53,7 +54,7 @@ df.app.orchestration("upload", function* (context: OrchestrationContext) { // Check if data is available from Shopify const response: Awaited> = yield context.df.callActivity("fileGet", body); - if (response.files.nodes.length > 0) { + if (response && response.files.nodes.length > 0) { fileUploaded = response.files.nodes[0]; } @@ -63,7 +64,7 @@ df.app.orchestration("upload", function* (context: OrchestrationContext) { if (fileUploaded) { return yield context.df.callActivity("updateCustomer", { customerId: body.customerId, - image: fileUploaded.preview.image, + image: fileUploaded.preview?.image, }); } @@ -74,9 +75,13 @@ df.app.orchestration("upload", function* (context: OrchestrationContext) { }; }); +type Node = FileGetQuery["files"]["nodes"][number]; +type PreviewType = NonNullable; +type ImageType = NonNullable; + type updateCustomer = { customerId: number; - image: PreviewImage["preview"]["image"]; + image: ImageType; }; df.app.activity("updateCustomer", { @@ -99,20 +104,17 @@ df.app.activity("updateCustomer", { }); async function fileCreate(input: Body) { - const response = await shopifyAdmin.query({ - data: { - query: FILE_CREATE, - variables: { - files: { - alt: getFilenameFromUrl(input.resourceUrl), - contentType: "IMAGE", - originalSource: input.resourceUrl, - }, + const { data } = await shopifyAdmin.request(FILE_CREATE, { + variables: { + files: { + alt: getFilenameFromUrl(input.resourceUrl), + contentType: "IMAGE" as any, + originalSource: input.resourceUrl, }, }, }); - return response.body.data; + return data; } df.app.activity("fileCreate", { @@ -120,16 +122,13 @@ df.app.activity("fileCreate", { }); async function fileGet(input: Body) { - const fileGet = await shopifyAdmin.query({ - data: { - query: FILE_GET, - variables: { - query: getFilenameFromUrl(input.resourceUrl), - }, + const { data } = await shopifyAdmin.request(FILE_GET, { + variables: { + query: getFilenameFromUrl(input.resourceUrl) || "", }, }); - return fileGet.body.data; + return data; } df.app.activity("fileGet", { @@ -180,29 +179,6 @@ const FILE_CREATE = `#graphql } ` as const; -type FileCreateQuery = { - data: { - fileCreate: { - files: Array<{ - fileStatus: string; - alt: string; - }>; - userErrors: Array; - }; - }; - extensions: { - cost: { - requestedQueryCost: number; - actualQueryCost: number; - throttleStatus: { - maximumAvailable: number; - currentlyAvailable: number; - restoreRate: number; - }; - }; - }; -}; - const FILE_GET = `#graphql query FileGet($query: String!) { files(first: 10, sortKey: UPDATED_AT, reverse: true, query: $query) { @@ -218,32 +194,3 @@ const FILE_GET = `#graphql } } ` as const; - -type PreviewImage = { - preview: { - image: { - url: string; - width: number; - height: number; - }; - }; -}; - -type FileGetQuery = { - data: { - files: { - nodes: Array; - }; - }; - extensions: { - cost: { - requestedQueryCost: number; - actualQueryCost: number; - throttleStatus: { - maximumAvailable: number; - currentlyAvailable: number; - restoreRate: number; - }; - }; - }; -}; diff --git a/src/functions/customer/controllers/product/create-variant.ts b/src/functions/customer/controllers/product/create-variant.ts new file mode 100644 index 00000000..99cbe5ca --- /dev/null +++ b/src/functions/customer/controllers/product/create-variant.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; +import { + ScheduleProductZodSchema, + ScheduleZodSchema, +} from "~/functions/schedule/schedule.types"; + +import { _ } from "~/library/handler"; +import { NumberOrStringType } from "~/library/zod"; +import { CustomerProductServiceCreateVariant } from "../../services/product/create-variant"; + +export type CustomerProductControllerCreateVariantRequest = { + query: z.infer; + body: z.infer; +}; + +const CustomerProductControllerCreateVariantQuerySchema = z.object({ + customerId: ScheduleZodSchema.shape.customerId, + productId: ScheduleProductZodSchema.shape.productId, +}); + +const CustomerProductControllerCreateVariantBodySchema = z.object({ + price: NumberOrStringType, + compareAtPrice: NumberOrStringType, +}); + +export type CustomerProductControllerCreateVariantResponse = Awaited< + ReturnType +>; + +export const CustomerProductControllerCreateVariant = _( + ({ query, body }: CustomerProductControllerCreateVariantRequest) => { + const validateQuery = + CustomerProductControllerCreateVariantQuerySchema.parse(query); + const validateBody = + CustomerProductControllerCreateVariantBodySchema.parse(body); + + return CustomerProductServiceCreateVariant({ + ...validateQuery, + ...validateBody, + }); + } +); diff --git a/src/functions/customer/controllers/product/destroy.spec.ts b/src/functions/customer/controllers/product/destroy.spec.ts index 4305538b..fc1f0fe9 100644 --- a/src/functions/customer/controllers/product/destroy.spec.ts +++ b/src/functions/customer/controllers/product/destroy.spec.ts @@ -9,7 +9,7 @@ import { import { omitObjectIdProps } from "~/library/jest/helpers"; import { getProductObject } from "~/library/jest/helpers/product"; -import { CustomerProductServiceUpsert } from "../../services/product"; +import { CustomerProductServiceUpsert } from "../../services/product/upsert"; import { CustomerScheduleServiceCreate } from "../../services/schedule/create"; import { CustomerProductControllerDestroy, diff --git a/src/functions/customer/controllers/product/destroy.ts b/src/functions/customer/controllers/product/destroy.ts index e6fe4d11..99af4edb 100644 --- a/src/functions/customer/controllers/product/destroy.ts +++ b/src/functions/customer/controllers/product/destroy.ts @@ -4,7 +4,7 @@ import { ScheduleZodSchema, } from "~/functions/schedule/schedule.types"; import { _ } from "~/library/handler"; -import { CustomerProductServiceDestroy } from "../../services/product"; +import { CustomerProductServiceDestroy } from "../../services/product/destroy"; export type CustomerProductControllerDestroyRequest = { query: z.infer; diff --git a/src/functions/customer/controllers/product/get.spec.ts b/src/functions/customer/controllers/product/get.spec.ts index d99d4229..a6fd7bfb 100644 --- a/src/functions/customer/controllers/product/get.spec.ts +++ b/src/functions/customer/controllers/product/get.spec.ts @@ -8,7 +8,7 @@ import { createHttpRequest, } from "~/library/jest/azure"; import { getProductObject } from "~/library/jest/helpers/product"; -import { CustomerProductServiceUpsert } from "../../services/product"; +import { CustomerProductServiceUpsert } from "../../services/product/upsert"; import { CustomerScheduleServiceCreate } from "../../services/schedule/create"; import { CustomerProductControllerGet, diff --git a/src/functions/customer/controllers/product/get.ts b/src/functions/customer/controllers/product/get.ts index 96b4c61e..741f9219 100644 --- a/src/functions/customer/controllers/product/get.ts +++ b/src/functions/customer/controllers/product/get.ts @@ -5,7 +5,7 @@ import { ScheduleZodSchema, } from "~/functions/schedule/schedule.types"; import { _ } from "~/library/handler"; -import { CustomerProductServiceGet } from "../../services/product"; +import { CustomerProductServiceGet } from "../../services/product/get"; export type CustomerProductControllerGetRequest = { query: z.infer; diff --git a/src/functions/customer/controllers/product/index.ts b/src/functions/customer/controllers/product/index.ts deleted file mode 100644 index 1292ba29..00000000 --- a/src/functions/customer/controllers/product/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./destroy"; -export * from "./get"; -export * from "./upsert"; diff --git a/src/functions/customer/controllers/product/upsert.ts b/src/functions/customer/controllers/product/upsert.ts index e43d9859..d6b22994 100644 --- a/src/functions/customer/controllers/product/upsert.ts +++ b/src/functions/customer/controllers/product/upsert.ts @@ -5,7 +5,7 @@ import { } from "~/functions/schedule/schedule.types"; import { _ } from "~/library/handler"; -import { CustomerProductServiceUpsert } from "../../services/product"; +import { CustomerProductServiceUpsert } from "../../services/product/upsert"; export type CustomerProductControllerUpsertRequest = { query: z.infer; diff --git a/src/functions/customer/controllers/products/list-ids.spec.ts b/src/functions/customer/controllers/products/list-ids.spec.ts index 8aef7e3b..05303c7c 100644 --- a/src/functions/customer/controllers/products/list-ids.spec.ts +++ b/src/functions/customer/controllers/products/list-ids.spec.ts @@ -1,6 +1,5 @@ import { HttpRequest, InvocationContext } from "@azure/functions"; -import { CustomerProductServiceUpsert } from "~/functions/customer/services/product"; import { TimeUnit } from "~/functions/schedule"; import { HttpSuccessResponse, @@ -8,6 +7,7 @@ import { createHttpRequest, } from "~/library/jest/azure"; import { getProductObject } from "~/library/jest/helpers/product"; +import { CustomerProductServiceUpsert } from "../../services/product/upsert"; import { CustomerScheduleServiceCreate } from "../../services/schedule/create"; import { CustomerProductsControllerListIds, diff --git a/src/functions/customer/controllers/products/list-ids.ts b/src/functions/customer/controllers/products/list-ids.ts index 70c65c1d..02d29fb3 100644 --- a/src/functions/customer/controllers/products/list-ids.ts +++ b/src/functions/customer/controllers/products/list-ids.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { _ } from "~/library/handler"; import { UserZodSchema } from "~/functions/user"; -import { CustomerProductsServiceListIds } from "../../services/product"; +import { CustomerProductsServiceListIds } from "../../services/product/list-ids"; export type CustomerProductsControllerListIdsRequest = { query: z.infer; diff --git a/src/functions/customer/controllers/products/list.spec.ts b/src/functions/customer/controllers/products/list.spec.ts index 9d0f8eb8..5d4c8dec 100644 --- a/src/functions/customer/controllers/products/list.spec.ts +++ b/src/functions/customer/controllers/products/list.spec.ts @@ -9,7 +9,7 @@ import { } from "~/library/jest/azure"; import { getProductObject } from "~/library/jest/helpers/product"; -import { CustomerProductServiceUpsert } from "../../services/product"; +import { CustomerProductServiceUpsert } from "../../services/product/upsert"; import { CustomerScheduleServiceCreate } from "../../services/schedule/create"; import { CustomerProductsControllerList, diff --git a/src/functions/customer/controllers/products/list.ts b/src/functions/customer/controllers/products/list.ts index 72f05e83..d79d8396 100644 --- a/src/functions/customer/controllers/products/list.ts +++ b/src/functions/customer/controllers/products/list.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { _ } from "~/library/handler"; import { UserZodSchema } from "~/functions/user"; -import { CustomerProductsServiceList } from "../../services/product"; +import { CustomerProductsServiceList } from "../../services/product/list"; export type CustomerProductsControllerListRequest = { query: z.infer; diff --git a/src/functions/customer/controllers/upload/resource-url.ts b/src/functions/customer/controllers/upload/resource-url.ts index 7af3efac..1552cdff 100644 --- a/src/functions/customer/controllers/upload/resource-url.ts +++ b/src/functions/customer/controllers/upload/resource-url.ts @@ -19,26 +19,32 @@ export type CustomerUploadControllerResourceURLResponse = Awaited< export const CustomerUploadControllerResourceURL = _( async ({ query }: CustomerUploadControllerResourceURLRequest) => { - const data = CustomerUploadControllerResourceURLQuerySchema.parse(query); - const response = await shopifyAdmin.query({ - data: { - query: UPLOAD_CREATE, - variables: { - input: [ - { - resource: "IMAGE", - filename: `${ - data.customerId - }_customer_profile_${new Date().getTime()}.jpg`, - mimeType: "image/jpeg", - httpMethod: "POST", - }, - ], - }, + const validateData = + CustomerUploadControllerResourceURLQuerySchema.parse(query); + const { data } = await shopifyAdmin.request(UPLOAD_CREATE, { + variables: { + input: [ + { + resource: "IMAGE" as any, + filename: `${ + validateData.customerId + }_customer_profile_${new Date().getTime()}.jpg`, + mimeType: "image/jpeg", + httpMethod: "POST" as any, + }, + ], }, }); - return response.body.data.stagedUploadsCreate.stagedTargets[0]; + if ( + !data || + !data.stagedUploadsCreate || + !data?.stagedUploadsCreate?.stagedTargets + ) { + throw new Error("something went wrong with uploading image"); + } + + return data?.stagedUploadsCreate?.stagedTargets[0]; } ); diff --git a/src/functions/customer/services/location.ts b/src/functions/customer/services/location.ts index 1504563f..19642383 100644 --- a/src/functions/customer/services/location.ts +++ b/src/functions/customer/services/location.ts @@ -22,7 +22,7 @@ import { UserServiceLocationsSetDefault, } from "~/functions/user"; import { NotFoundError } from "~/library/handler"; -import { CustomerProductServiceRemoveLocationFromAll } from "./product"; +import { CustomerProductServiceRemoveLocationFromAll } from "./product/remove-location-from-all"; export const CustomerLocationServiceCreate = async ( body: LocationServiceCreateProps diff --git a/src/functions/customer/services/product.spec.ts b/src/functions/customer/services/product.spec.ts deleted file mode 100644 index f6c8673a..00000000 --- a/src/functions/customer/services/product.spec.ts +++ /dev/null @@ -1,412 +0,0 @@ -import mongoose from "mongoose"; - -import { LocationTypes } from "~/functions/location"; -import { TimeUnit } from "~/functions/schedule"; -import { omitObjectIdProps } from "~/library/jest/helpers"; -import { getProductObject } from "~/library/jest/helpers/product"; -import { - CustomerProductServiceDestroy, - CustomerProductServiceGet, - CustomerProductServiceRemoveLocationFromAll, - CustomerProductServiceUpsert, - CustomerProductsServiceList, - CustomerProductsServiceListIds, -} from "./product"; -import { CustomerScheduleServiceCreate } from "./schedule/create"; -import { CustomerScheduleServiceGet } from "./schedule/get"; - -require("~/library/jest/mongoose/mongodb.jest"); - -describe("CustomerProductsService", () => { - const customerId = 123; - const name = "Test Schedule"; - const productId = 1000; - const newProduct = getProductObject({ - variantId: 1, - duration: 60, - breakTime: 0, - noticePeriod: { - value: 1, - unit: TimeUnit.DAYS, - }, - bookingPeriod: { - value: 1, - unit: TimeUnit.WEEKS, - }, - locations: [], - }); - - it("should get all productIds for all schedules", async () => { - const schedule1 = await CustomerScheduleServiceCreate({ - name: "ab", - customerId: 7, - }); - - const product1 = getProductObject({ - variantId: 1, - duration: 60, - breakTime: 0, - noticePeriod: { - value: 1, - unit: TimeUnit.DAYS, - }, - bookingPeriod: { - value: 1, - unit: TimeUnit.WEEKS, - }, - locations: [], - }); - - await CustomerProductServiceUpsert( - { - customerId: schedule1.customerId, - productId: 999, - }, - { ...product1, scheduleId: schedule1._id } - ); - - const schedule2 = await CustomerScheduleServiceCreate({ - name: "ab", - customerId, - }); - - const product2 = { ...product1, scheduleId: schedule2._id }; - - await CustomerProductServiceUpsert( - { - customerId: schedule2.customerId, - productId: 1001, - }, - product2 - ); - - await CustomerProductServiceUpsert( - { - customerId: schedule2.customerId, - productId: 1000, - }, - product2 - ); - - const schedule3 = await CustomerScheduleServiceCreate({ - name: "test", - customerId, - }); - - const product3 = { - ...product1, - scheduleId: schedule3._id, - }; - - await CustomerProductServiceUpsert( - { - customerId: schedule3.customerId, - productId: 1002, - }, - product3 - ); - - await CustomerProductServiceUpsert( - { - customerId: schedule3.customerId, - productId: 1004, - }, - product3 - ); - - const products = await CustomerProductsServiceListIds({ customerId }); - expect(products).toEqual([1001, 1000, 1002, 1004]); - }); - - it("should get all products for all schedules", async () => { - const schedule1 = await CustomerScheduleServiceCreate({ - name: "ab", - customerId, - }); - - const product1 = getProductObject({ - variantId: 1, - duration: 60, - breakTime: 0, - noticePeriod: { - value: 1, - unit: TimeUnit.DAYS, - }, - bookingPeriod: { - value: 1, - unit: TimeUnit.WEEKS, - }, - locations: [], - }); - - await CustomerProductServiceUpsert( - { - customerId: schedule1.customerId, - productId: 1001, - }, - { ...product1, scheduleId: schedule1._id } - ); - - await CustomerProductServiceUpsert( - { - customerId: schedule1.customerId, - productId: 1000, - }, - { ...product1, scheduleId: schedule1._id } - ); - - const newSchedule2 = await CustomerScheduleServiceCreate({ - name: "test", - customerId, - }); - - const product2 = { ...product1, scheduleId: newSchedule2._id }; - - await CustomerProductServiceUpsert( - { - customerId: newSchedule2.customerId, - productId: 1002, - }, - product2 - ); - - await CustomerProductServiceUpsert( - { - customerId: newSchedule2.customerId, - productId: 1004, - }, - product2 - ); - - const products = await CustomerProductsServiceList({ customerId }); - expect(products).toHaveLength(4); - }); - - it("should add a new product to the schedule", async () => { - const newSchedule = await CustomerScheduleServiceCreate({ - name, - customerId, - }); - - const updateProduct = await CustomerProductServiceUpsert( - { - customerId: newSchedule.customerId, - productId, - }, - { ...newProduct, scheduleId: newSchedule._id } - ); - - expect(updateProduct).toMatchObject({ - ...newProduct, - productId, - scheduleId: newSchedule._id.toString(), - }); - }); - - it("should be able to remove one location from all products", async () => { - const newSchedule1 = await CustomerScheduleServiceCreate({ - name, - customerId, - }); - const locationRemoveId = new mongoose.Types.ObjectId().toString(); - await CustomerProductServiceUpsert( - { - customerId: newSchedule1.customerId, - productId, - }, - { - ...newProduct, - scheduleId: newSchedule1._id, - locations: [ - { - location: locationRemoveId, - locationType: LocationTypes.ORIGIN, - }, - { - location: new mongoose.Types.ObjectId(), - locationType: LocationTypes.ORIGIN, - }, - ], - } - ); - - await CustomerProductServiceUpsert( - { - customerId: newSchedule1.customerId, - productId: 22, - }, - { - ...newProduct, - scheduleId: newSchedule1._id, - locations: [ - { - location: locationRemoveId, - locationType: LocationTypes.ORIGIN, - }, - { - location: new mongoose.Types.ObjectId().toString(), - locationType: LocationTypes.DESTINATION, - }, - ], - } - ); - - const newSchedule2 = await CustomerScheduleServiceCreate({ - name: "test2", - customerId, - }); - - await CustomerProductServiceUpsert( - { - customerId: newSchedule2.customerId, - productId: 232, - }, - { - ...newProduct, - scheduleId: newSchedule2._id, - locations: [ - { - location: locationRemoveId, - locationType: LocationTypes.ORIGIN, - }, - ], - } - ); - - let getSchedule1 = await CustomerScheduleServiceGet({ - customerId, - scheduleId: newSchedule1.id, - }); - - getSchedule1.products.forEach((product) => { - const locationIds = product.locations.map((location) => - location.location.toString() - ); - expect(locationIds).toContain(locationRemoveId); - }); - - let getSchedule2 = await CustomerScheduleServiceGet({ - customerId, - scheduleId: newSchedule2.id, - }); - - getSchedule2.products.forEach((product) => { - const locationIds = product.locations.map((location) => - location.location.toString() - ); - expect(locationIds).toContain(locationRemoveId); - }); - - expect(getSchedule1.products[0].locations).toHaveLength(2); - expect(getSchedule1.products[1].locations).toHaveLength(2); - expect(getSchedule2.products[0].locations).toHaveLength(1); - - await CustomerProductServiceRemoveLocationFromAll({ - locationId: locationRemoveId, - customerId, - }); - - getSchedule1 = await CustomerScheduleServiceGet({ - customerId, - scheduleId: newSchedule1.id, - }); - - getSchedule2 = await CustomerScheduleServiceGet({ - customerId, - scheduleId: newSchedule2.id, - }); - - expect(getSchedule1.products[0].locations).toHaveLength(1); - expect(getSchedule1.products[1].locations).toHaveLength(1); - expect(getSchedule2.products[0].locations).toHaveLength(0); - - getSchedule1.products.forEach((product) => { - const locationIds = product.locations.map((location) => - location.location.toString() - ); - expect(locationIds).not.toContain(locationRemoveId); - }); - }); - - it("should find a product", async () => { - const newSchedule = await CustomerScheduleServiceCreate({ - name, - customerId, - }); - - const updatedSchedule = await CustomerProductServiceUpsert( - { - customerId: newSchedule.customerId, - productId, - }, - { ...newProduct, scheduleId: newSchedule._id } - ); - - const foundProduct = await CustomerProductServiceGet({ - customerId: newSchedule.customerId, - productId, - }); - - expect(foundProduct).toMatchObject({ productId }); - }); - - it("should update an existing product in the schedule", async () => { - const newSchedule = await CustomerScheduleServiceCreate({ - name, - customerId, - }); - - await CustomerProductServiceUpsert( - { - customerId: newSchedule.customerId, - productId, - }, - { ...newProduct, scheduleId: newSchedule._id } - ); - - const productBody = { - ...newProduct, - duration: 90, - scheduleId: newSchedule._id, - }; - - let updateProduct = await CustomerProductServiceUpsert( - { - customerId: newSchedule.customerId, - productId, - }, - productBody - ); - - expect(omitObjectIdProps(updateProduct)).toEqual( - expect.objectContaining( - omitObjectIdProps({ - ...productBody, - productId: updateProduct.productId, - }) - ) - ); - }); - - it("should remove an existing product from the schedule", async () => { - const newSchedule = await CustomerScheduleServiceCreate({ - name, - customerId, - }); - - await CustomerProductServiceUpsert( - { - customerId: newSchedule.customerId, - productId, - }, - { ...newProduct, scheduleId: newSchedule._id } - ); - - const updatedSchedule = await CustomerProductServiceDestroy({ - customerId: newSchedule.customerId, - productId, - }); - - expect(updatedSchedule?.modifiedCount).toBe(1); - }); -}); diff --git a/src/functions/customer/services/product.ts b/src/functions/customer/services/product.ts deleted file mode 100644 index 36302412..00000000 --- a/src/functions/customer/services/product.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { z } from "zod"; -import { - Schedule, - ScheduleModel, - ScheduleProduct, - ScheduleZodSchema, -} from "~/functions/schedule"; -import { NotFoundError } from "~/library/handler"; - -type CustomerProductsServiceListIdsProps = { - customerId: Schedule["customerId"]; -}; - -export const CustomerProductsServiceListIds = async ( - filter: CustomerProductsServiceListIdsProps -) => { - const schedules = await ScheduleModel.find(filter).select( - "products.productId" - ); - - return schedules.flatMap((schedule) => - schedule.products.map((product) => product.productId) - ); -}; - -type CustomerProductsServiceListProps = { - customerId: Schedule["customerId"]; - scheduleId?: Schedule["_id"]; -}; - -export const CustomerProductsServiceList = async ({ - customerId, - scheduleId, -}: CustomerProductsServiceListProps) => { - let query: any = { customerId }; - if (scheduleId !== undefined) { - query._id = scheduleId; - } - - const schedules = await ScheduleModel.find(query) - .select("name products") - .lean(); - - return schedules.flatMap((schedule) => - schedule.products.map((product) => ({ - scheduleId: schedule._id, - scheduleName: schedule.name, - ...product, - })) - ); -}; - -export type CustomerProductServiceDestroyFilter = { - customerId: Schedule["customerId"]; - productId: ScheduleProduct["productId"]; -}; - -export const CustomerProductServiceDestroy = async ( - filter: CustomerProductServiceDestroyFilter -) => { - try { - return ScheduleModel.updateOne( - { - customerId: filter.customerId, - products: { - $elemMatch: { - productId: filter.productId, - }, - }, - }, - { $pull: { products: { productId: filter.productId } } }, - { new: true } - ).lean(); - } catch (error) { - console.error("Error destroying product:", error); - } -}; - -export type CustomerProductServiceUpsert = { - customerId: Schedule["customerId"]; - productId: ScheduleProduct["productId"]; -}; - -export type CustomerProductServiceUpsertBody = Omit< - ScheduleProduct, - "productId" -> & { - scheduleId: z.infer; -}; - -export const CustomerProductServiceUpsert = async ( - filter: CustomerProductServiceUpsert, - product: CustomerProductServiceUpsertBody -) => { - await CustomerProductServiceDestroy(filter); - const schedule = await ScheduleModel.findOneAndUpdate( - { - _id: product.scheduleId, - customerId: filter.customerId, - }, - { $push: { products: { ...product, productId: filter.productId } } }, - { new: true, upsert: true } - ) - .orFail( - new NotFoundError([ - { - code: "custom", - message: "PRODUCT_NOT_FOUND", - path: ["productId"], - }, - ]) - ) - .lean(); - - return { - ...product, - productId: filter.productId, - scheduleId: schedule._id.toString(), - scheduleName: schedule.name, - }; -}; - -export type CustomerProductServiceGetFilter = { - customerId: Schedule["customerId"]; - productId: ScheduleProduct["productId"]; -}; - -export const CustomerProductServiceGet = async ( - filter: CustomerProductServiceGetFilter -) => { - const schedule = await ScheduleModel.findOne({ - customerId: filter.customerId, - products: { - $elemMatch: { - productId: filter.productId, - }, - }, - }) - .orFail( - new NotFoundError([ - { - code: "custom", - message: "PRODUCT_NOT_FOUND", - path: ["productId"], - }, - ]) - ) - .lean(); - - const product = schedule.products.find( - (p) => p.productId === filter.productId - ); - - if (!product) { - throw new NotFoundError([ - { - code: "custom", - message: "PRODUCT_NOT_FOUND", - path: ["productId"], - }, - ]); - } - - return { - ...product, - scheduleId: schedule._id, - scheduleName: schedule.name, - }; -}; - -export const CustomerProductServiceRemoveLocationFromAll = async (filter: { - locationId: string; - customerId: number; -}) => { - const schedules = await ScheduleModel.find({ customerId: filter.customerId }); - - for (let schedule of schedules) { - for (let product of schedule.products) { - product.locations = product.locations.filter( - (location) => - location.location.toString() !== filter.locationId.toString() - ); - } - - await schedule.save(); - } -}; diff --git a/src/functions/customer/services/product/create-variant.ts b/src/functions/customer/services/product/create-variant.ts new file mode 100644 index 00000000..2eef9c3a --- /dev/null +++ b/src/functions/customer/services/product/create-variant.ts @@ -0,0 +1,54 @@ +import { shopifyAdmin } from "~/library/shopify"; + +export type CustomerProductServiceCreateVariantProps = { + productId: number; + price: number; + compareAtPrice: number; +}; + +export const CustomerProductServiceCreateVariant = async ( + props: CustomerProductServiceCreateVariantProps +) => { + const { data } = await shopifyAdmin.request(CREATE_VARIANT, { + variables: { + input: { + price: props.price, + compareAtPrice: props.compareAtPrice, + productId: `gid://shopify/Product/${props.productId}`, + inventoryItem: { + tracked: false, + }, + options: [`Artist ${props.price}.${props.compareAtPrice}`], + }, + }, + }); + + if ( + data && + data.productVariantCreate && + data.productVariantCreate?.userErrors.length > 0 + ) { + throw data.productVariantCreate.userErrors[0]; + } + + return data?.productVariantCreate?.productVariant; +}; + +const CREATE_VARIANT = `#graphql + mutation productVariantCreate($input: ProductVariantInput!) { + productVariantCreate(input: $input) { + productVariant { + id + title + selectedOptions { + name + value + } + } + userErrors { + field + message + } + } + } +` as const; diff --git a/src/functions/customer/services/product/destroy.spec.ts b/src/functions/customer/services/product/destroy.spec.ts new file mode 100644 index 00000000..f470b437 --- /dev/null +++ b/src/functions/customer/services/product/destroy.spec.ts @@ -0,0 +1,49 @@ +import { TimeUnit } from "~/functions/schedule"; +import { getProductObject } from "~/library/jest/helpers/product"; +import { CustomerScheduleServiceCreate } from "../schedule/create"; +import { CustomerProductServiceDestroy } from "./destroy"; +import { CustomerProductServiceUpsert } from "./upsert"; + +require("~/library/jest/mongoose/mongodb.jest"); + +describe("CustomerProductServiceDestroy", () => { + const customerId = 123; + const name = "Test Schedule"; + const productId = 1000; + const newProduct = getProductObject({ + variantId: 1, + duration: 60, + breakTime: 0, + noticePeriod: { + value: 1, + unit: TimeUnit.DAYS, + }, + bookingPeriod: { + value: 1, + unit: TimeUnit.WEEKS, + }, + locations: [], + }); + + it("should remove an existing product from the schedule", async () => { + const newSchedule = await CustomerScheduleServiceCreate({ + name, + customerId, + }); + + await CustomerProductServiceUpsert( + { + customerId: newSchedule.customerId, + productId, + }, + { ...newProduct, scheduleId: newSchedule._id } + ); + + const updatedSchedule = await CustomerProductServiceDestroy({ + customerId: newSchedule.customerId, + productId, + }); + + expect(updatedSchedule?.modifiedCount).toBe(1); + }); +}); diff --git a/src/functions/customer/services/product/destroy.ts b/src/functions/customer/services/product/destroy.ts new file mode 100644 index 00000000..0f484375 --- /dev/null +++ b/src/functions/customer/services/product/destroy.ts @@ -0,0 +1,27 @@ +import { Schedule, ScheduleModel, ScheduleProduct } from "~/functions/schedule"; + +export type CustomerProductServiceDestroyFilter = { + customerId: Schedule["customerId"]; + productId: ScheduleProduct["productId"]; +}; + +export const CustomerProductServiceDestroy = async ( + filter: CustomerProductServiceDestroyFilter +) => { + try { + return ScheduleModel.updateOne( + { + customerId: filter.customerId, + products: { + $elemMatch: { + productId: filter.productId, + }, + }, + }, + { $pull: { products: { productId: filter.productId } } }, + { new: true } + ).lean(); + } catch (error) { + console.error("Error destroying product:", error); + } +}; diff --git a/src/functions/customer/services/product/get.spec.ts b/src/functions/customer/services/product/get.spec.ts new file mode 100644 index 00000000..e594380d --- /dev/null +++ b/src/functions/customer/services/product/get.spec.ts @@ -0,0 +1,49 @@ +import { TimeUnit } from "~/functions/schedule"; +import { getProductObject } from "~/library/jest/helpers/product"; +import { CustomerScheduleServiceCreate } from "../schedule/create"; +import { CustomerProductServiceGet } from "./get"; +import { CustomerProductServiceUpsert } from "./upsert"; + +require("~/library/jest/mongoose/mongodb.jest"); + +describe("CustomerProductsService", () => { + const customerId = 123; + const name = "Test Schedule"; + const productId = 1000; + const newProduct = getProductObject({ + variantId: 1, + duration: 60, + breakTime: 0, + noticePeriod: { + value: 1, + unit: TimeUnit.DAYS, + }, + bookingPeriod: { + value: 1, + unit: TimeUnit.WEEKS, + }, + locations: [], + }); + + it("should find a product", async () => { + const newSchedule = await CustomerScheduleServiceCreate({ + name, + customerId, + }); + + const updatedSchedule = await CustomerProductServiceUpsert( + { + customerId: newSchedule.customerId, + productId, + }, + { ...newProduct, scheduleId: newSchedule._id } + ); + + const foundProduct = await CustomerProductServiceGet({ + customerId: newSchedule.customerId, + productId, + }); + + expect(foundProduct).toMatchObject({ productId }); + }); +}); diff --git a/src/functions/customer/services/product/get.ts b/src/functions/customer/services/product/get.ts new file mode 100644 index 00000000..6b09d48c --- /dev/null +++ b/src/functions/customer/services/product/get.ts @@ -0,0 +1,50 @@ +import { Schedule, ScheduleModel, ScheduleProduct } from "~/functions/schedule"; +import { NotFoundError } from "~/library/handler"; + +export type CustomerProductServiceGetFilter = { + customerId: Schedule["customerId"]; + productId: ScheduleProduct["productId"]; +}; + +export const CustomerProductServiceGet = async ( + filter: CustomerProductServiceGetFilter +) => { + const schedule = await ScheduleModel.findOne({ + customerId: filter.customerId, + products: { + $elemMatch: { + productId: filter.productId, + }, + }, + }) + .orFail( + new NotFoundError([ + { + code: "custom", + message: "PRODUCT_NOT_FOUND", + path: ["productId"], + }, + ]) + ) + .lean(); + + const product = schedule.products.find( + (p) => p.productId === filter.productId + ); + + if (!product) { + throw new NotFoundError([ + { + code: "custom", + message: "PRODUCT_NOT_FOUND", + path: ["productId"], + }, + ]); + } + + return { + ...product, + scheduleId: schedule._id, + scheduleName: schedule.name, + }; +}; diff --git a/src/functions/customer/services/product/list-ids.spec.ts b/src/functions/customer/services/product/list-ids.spec.ts new file mode 100644 index 00000000..4b8fe08b --- /dev/null +++ b/src/functions/customer/services/product/list-ids.spec.ts @@ -0,0 +1,109 @@ +import { TimeUnit } from "~/functions/schedule"; +import { getProductObject } from "~/library/jest/helpers/product"; +import { CustomerScheduleServiceCreate } from "../schedule/create"; +import { CustomerProductsServiceListIds } from "./list-ids"; +import { CustomerProductServiceUpsert } from "./upsert"; + +require("~/library/jest/mongoose/mongodb.jest"); + +describe("CustomerProductsServiceListIds", () => { + const customerId = 123; + const name = "Test Schedule"; + const productId = 1000; + const newProduct = getProductObject({ + variantId: 1, + duration: 60, + breakTime: 0, + noticePeriod: { + value: 1, + unit: TimeUnit.DAYS, + }, + bookingPeriod: { + value: 1, + unit: TimeUnit.WEEKS, + }, + locations: [], + }); + + it("should get all productIds for all schedules", async () => { + const schedule1 = await CustomerScheduleServiceCreate({ + name: "ab", + customerId: 7, + }); + + const product1 = getProductObject({ + variantId: 1, + duration: 60, + breakTime: 0, + noticePeriod: { + value: 1, + unit: TimeUnit.DAYS, + }, + bookingPeriod: { + value: 1, + unit: TimeUnit.WEEKS, + }, + locations: [], + }); + + await CustomerProductServiceUpsert( + { + customerId: schedule1.customerId, + productId: 999, + }, + { ...product1, scheduleId: schedule1._id } + ); + + const schedule2 = await CustomerScheduleServiceCreate({ + name: "ab", + customerId, + }); + + const product2 = { ...product1, scheduleId: schedule2._id }; + + await CustomerProductServiceUpsert( + { + customerId: schedule2.customerId, + productId: 1001, + }, + product2 + ); + + await CustomerProductServiceUpsert( + { + customerId: schedule2.customerId, + productId: 1000, + }, + product2 + ); + + const schedule3 = await CustomerScheduleServiceCreate({ + name: "test", + customerId, + }); + + const product3 = { + ...product1, + scheduleId: schedule3._id, + }; + + await CustomerProductServiceUpsert( + { + customerId: schedule3.customerId, + productId: 1002, + }, + product3 + ); + + await CustomerProductServiceUpsert( + { + customerId: schedule3.customerId, + productId: 1004, + }, + product3 + ); + + const products = await CustomerProductsServiceListIds({ customerId }); + expect(products).toEqual([1001, 1000, 1002, 1004]); + }); +}); diff --git a/src/functions/customer/services/product/list-ids.ts b/src/functions/customer/services/product/list-ids.ts new file mode 100644 index 00000000..92f12c51 --- /dev/null +++ b/src/functions/customer/services/product/list-ids.ts @@ -0,0 +1,22 @@ +import { Schedule, ScheduleModel } from "~/functions/schedule"; + +type CustomerProductsServiceListIdsProps = { + customerId: Schedule["customerId"]; +}; + +export const CustomerProductsServiceListIds = async ( + filter: CustomerProductsServiceListIdsProps +) => { + const schedules = await ScheduleModel.find(filter).select( + "products.productId" + ); + + return schedules.flatMap((schedule) => + schedule.products.map((product) => product.productId) + ); +}; + +type CustomerProductsServiceListProps = { + customerId: Schedule["customerId"]; + scheduleId?: Schedule["_id"]; +}; diff --git a/src/functions/customer/services/product/list.spec.ts b/src/functions/customer/services/product/list.spec.ts new file mode 100644 index 00000000..83db9c07 --- /dev/null +++ b/src/functions/customer/services/product/list.spec.ts @@ -0,0 +1,91 @@ +import { TimeUnit } from "~/functions/schedule"; +import { getProductObject } from "~/library/jest/helpers/product"; +import { CustomerScheduleServiceCreate } from "../schedule/create"; +import { CustomerProductsServiceList } from "./list"; +import { CustomerProductServiceUpsert } from "./upsert"; + +require("~/library/jest/mongoose/mongodb.jest"); + +describe("CustomerProductsServiceList", () => { + const customerId = 123; + const name = "Test Schedule"; + const productId = 1000; + const newProduct = getProductObject({ + variantId: 1, + duration: 60, + breakTime: 0, + noticePeriod: { + value: 1, + unit: TimeUnit.DAYS, + }, + bookingPeriod: { + value: 1, + unit: TimeUnit.WEEKS, + }, + locations: [], + }); + + it("should get all products for all schedules", async () => { + const schedule1 = await CustomerScheduleServiceCreate({ + name: "ab", + customerId, + }); + + const product1 = getProductObject({ + variantId: 1, + duration: 60, + breakTime: 0, + noticePeriod: { + value: 1, + unit: TimeUnit.DAYS, + }, + bookingPeriod: { + value: 1, + unit: TimeUnit.WEEKS, + }, + locations: [], + }); + + await CustomerProductServiceUpsert( + { + customerId: schedule1.customerId, + productId: 1001, + }, + { ...product1, scheduleId: schedule1._id } + ); + + await CustomerProductServiceUpsert( + { + customerId: schedule1.customerId, + productId: 1000, + }, + { ...product1, scheduleId: schedule1._id } + ); + + const newSchedule2 = await CustomerScheduleServiceCreate({ + name: "test", + customerId, + }); + + const product2 = { ...product1, scheduleId: newSchedule2._id }; + + await CustomerProductServiceUpsert( + { + customerId: newSchedule2.customerId, + productId: 1002, + }, + product2 + ); + + await CustomerProductServiceUpsert( + { + customerId: newSchedule2.customerId, + productId: 1004, + }, + product2 + ); + + const products = await CustomerProductsServiceList({ customerId }); + expect(products).toHaveLength(4); + }); +}); diff --git a/src/functions/customer/services/product/list.ts b/src/functions/customer/services/product/list.ts new file mode 100644 index 00000000..e7ce4052 --- /dev/null +++ b/src/functions/customer/services/product/list.ts @@ -0,0 +1,28 @@ +import { Schedule, ScheduleModel } from "~/functions/schedule"; + +type CustomerProductsServiceListProps = { + customerId: Schedule["customerId"]; + scheduleId?: Schedule["_id"]; +}; + +export const CustomerProductsServiceList = async ({ + customerId, + scheduleId, +}: CustomerProductsServiceListProps) => { + let query: any = { customerId }; + if (scheduleId !== undefined) { + query._id = scheduleId; + } + + const schedules = await ScheduleModel.find(query) + .select("name products") + .lean(); + + return schedules.flatMap((schedule) => + schedule.products.map((product) => ({ + scheduleId: schedule._id, + scheduleName: schedule.name, + ...product, + })) + ); +}; diff --git a/src/functions/customer/services/product/remove-location-from-all.spec.ts b/src/functions/customer/services/product/remove-location-from-all.spec.ts new file mode 100644 index 00000000..9f913633 --- /dev/null +++ b/src/functions/customer/services/product/remove-location-from-all.spec.ts @@ -0,0 +1,156 @@ +import mongoose from "mongoose"; + +import { LocationTypes } from "~/functions/location"; +import { TimeUnit } from "~/functions/schedule"; +import { getProductObject } from "~/library/jest/helpers/product"; +import { CustomerScheduleServiceCreate } from "../schedule/create"; +import { CustomerScheduleServiceGet } from "../schedule/get"; +import { CustomerProductServiceRemoveLocationFromAll } from "./remove-location-from-all"; +import { CustomerProductServiceUpsert } from "./upsert"; + +require("~/library/jest/mongoose/mongodb.jest"); + +describe("CustomerProductServiceRemoveLocationFromAll", () => { + const customerId = 123; + const name = "Test Schedule"; + const productId = 1000; + const newProduct = getProductObject({ + variantId: 1, + duration: 60, + breakTime: 0, + noticePeriod: { + value: 1, + unit: TimeUnit.DAYS, + }, + bookingPeriod: { + value: 1, + unit: TimeUnit.WEEKS, + }, + locations: [], + }); + + it("should be able to remove one location from all products", async () => { + const newSchedule1 = await CustomerScheduleServiceCreate({ + name, + customerId, + }); + const locationRemoveId = new mongoose.Types.ObjectId().toString(); + await CustomerProductServiceUpsert( + { + customerId: newSchedule1.customerId, + productId, + }, + { + ...newProduct, + scheduleId: newSchedule1._id, + locations: [ + { + location: locationRemoveId, + locationType: LocationTypes.ORIGIN, + }, + { + location: new mongoose.Types.ObjectId(), + locationType: LocationTypes.ORIGIN, + }, + ], + } + ); + + await CustomerProductServiceUpsert( + { + customerId: newSchedule1.customerId, + productId: 22, + }, + { + ...newProduct, + scheduleId: newSchedule1._id, + locations: [ + { + location: locationRemoveId, + locationType: LocationTypes.ORIGIN, + }, + { + location: new mongoose.Types.ObjectId().toString(), + locationType: LocationTypes.DESTINATION, + }, + ], + } + ); + + const newSchedule2 = await CustomerScheduleServiceCreate({ + name: "test2", + customerId, + }); + + await CustomerProductServiceUpsert( + { + customerId: newSchedule2.customerId, + productId: 232, + }, + { + ...newProduct, + scheduleId: newSchedule2._id, + locations: [ + { + location: locationRemoveId, + locationType: LocationTypes.ORIGIN, + }, + ], + } + ); + + let getSchedule1 = await CustomerScheduleServiceGet({ + customerId, + scheduleId: newSchedule1.id, + }); + + getSchedule1.products.forEach((product) => { + const locationIds = product.locations.map((location) => + location.location.toString() + ); + expect(locationIds).toContain(locationRemoveId); + }); + + let getSchedule2 = await CustomerScheduleServiceGet({ + customerId, + scheduleId: newSchedule2.id, + }); + + getSchedule2.products.forEach((product) => { + const locationIds = product.locations.map((location) => + location.location.toString() + ); + expect(locationIds).toContain(locationRemoveId); + }); + + expect(getSchedule1.products[0].locations).toHaveLength(2); + expect(getSchedule1.products[1].locations).toHaveLength(2); + expect(getSchedule2.products[0].locations).toHaveLength(1); + + await CustomerProductServiceRemoveLocationFromAll({ + locationId: locationRemoveId, + customerId, + }); + + getSchedule1 = await CustomerScheduleServiceGet({ + customerId, + scheduleId: newSchedule1.id, + }); + + getSchedule2 = await CustomerScheduleServiceGet({ + customerId, + scheduleId: newSchedule2.id, + }); + + expect(getSchedule1.products[0].locations).toHaveLength(1); + expect(getSchedule1.products[1].locations).toHaveLength(1); + expect(getSchedule2.products[0].locations).toHaveLength(0); + + getSchedule1.products.forEach((product) => { + const locationIds = product.locations.map((location) => + location.location.toString() + ); + expect(locationIds).not.toContain(locationRemoveId); + }); + }); +}); diff --git a/src/functions/customer/services/product/remove-location-from-all.ts b/src/functions/customer/services/product/remove-location-from-all.ts new file mode 100644 index 00000000..b3247749 --- /dev/null +++ b/src/functions/customer/services/product/remove-location-from-all.ts @@ -0,0 +1,19 @@ +import { ScheduleModel } from "~/functions/schedule"; + +export const CustomerProductServiceRemoveLocationFromAll = async (filter: { + locationId: string; + customerId: number; +}) => { + const schedules = await ScheduleModel.find({ customerId: filter.customerId }); + + for (let schedule of schedules) { + for (let product of schedule.products) { + product.locations = product.locations.filter( + (location) => + location.location.toString() !== filter.locationId.toString() + ); + } + + await schedule.save(); + } +}; diff --git a/src/functions/customer/services/product/upsert.spec.ts b/src/functions/customer/services/product/upsert.spec.ts new file mode 100644 index 00000000..95bc8ee5 --- /dev/null +++ b/src/functions/customer/services/product/upsert.spec.ts @@ -0,0 +1,86 @@ +import { TimeUnit } from "~/functions/schedule"; +import { omitObjectIdProps } from "~/library/jest/helpers"; +import { getProductObject } from "~/library/jest/helpers/product"; +import { CustomerScheduleServiceCreate } from "../schedule/create"; +import { CustomerProductServiceUpsert } from "./upsert"; + +require("~/library/jest/mongoose/mongodb.jest"); + +describe("CustomerProductServiceUpsert", () => { + const customerId = 123; + const name = "Test Schedule"; + const productId = 1000; + const newProduct = getProductObject({ + variantId: 1, + duration: 60, + breakTime: 0, + noticePeriod: { + value: 1, + unit: TimeUnit.DAYS, + }, + bookingPeriod: { + value: 1, + unit: TimeUnit.WEEKS, + }, + locations: [], + }); + + it("should add a new product to the schedule", async () => { + const newSchedule = await CustomerScheduleServiceCreate({ + name, + customerId, + }); + + const updateProduct = await CustomerProductServiceUpsert( + { + customerId: newSchedule.customerId, + productId, + }, + { ...newProduct, scheduleId: newSchedule._id } + ); + + expect(updateProduct).toMatchObject({ + ...newProduct, + productId, + scheduleId: newSchedule._id.toString(), + }); + }); + + it("should update an existing product in the schedule", async () => { + const newSchedule = await CustomerScheduleServiceCreate({ + name, + customerId, + }); + + await CustomerProductServiceUpsert( + { + customerId: newSchedule.customerId, + productId, + }, + { ...newProduct, scheduleId: newSchedule._id } + ); + + const productBody = { + ...newProduct, + duration: 90, + scheduleId: newSchedule._id, + }; + + let updateProduct = await CustomerProductServiceUpsert( + { + customerId: newSchedule.customerId, + productId, + }, + productBody + ); + + expect(omitObjectIdProps(updateProduct)).toEqual( + expect.objectContaining( + omitObjectIdProps({ + ...productBody, + productId: updateProduct.productId, + }) + ) + ); + }); +}); diff --git a/src/functions/customer/services/product/upsert.ts b/src/functions/customer/services/product/upsert.ts new file mode 100644 index 00000000..66b1043b --- /dev/null +++ b/src/functions/customer/services/product/upsert.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; +import { + Schedule, + ScheduleModel, + ScheduleProduct, + ScheduleZodSchema, +} from "~/functions/schedule"; +import { NotFoundError } from "~/library/handler"; +import { CustomerProductServiceDestroy } from "./destroy"; + +export type CustomerProductServiceUpsert = { + customerId: Schedule["customerId"]; + productId: ScheduleProduct["productId"]; +}; + +export type CustomerProductServiceUpsertBody = Omit< + ScheduleProduct, + "productId" +> & { + scheduleId: z.infer; +}; + +export const CustomerProductServiceUpsert = async ( + filter: CustomerProductServiceUpsert, + product: CustomerProductServiceUpsertBody +) => { + await CustomerProductServiceDestroy(filter); + const schedule = await ScheduleModel.findOneAndUpdate( + { + _id: product.scheduleId, + customerId: filter.customerId, + }, + { $push: { products: { ...product, productId: filter.productId } } }, + { new: true, upsert: true } + ) + .orFail( + new NotFoundError([ + { + code: "custom", + message: "PRODUCT_NOT_FOUND", + path: ["productId"], + }, + ]) + ) + .lean(); + + return { + ...product, + productId: filter.productId, + scheduleId: schedule._id.toString(), + scheduleName: schedule.name, + }; +}; diff --git a/src/functions/schedule/schemas/product.schema.ts b/src/functions/schedule/schemas/product.schema.ts index 631c40b8..dd51322e 100644 --- a/src/functions/schedule/schemas/product.schema.ts +++ b/src/functions/schedule/schemas/product.schema.ts @@ -14,6 +14,7 @@ export const ProductSchema = new mongoose.Schema( }, variantId: { type: Number, + index: true, }, selectedOptions: { name: String, diff --git a/src/functions/user/controllers/products/get.spec.ts b/src/functions/user/controllers/products/get.spec.ts index 2345569a..4aabfe89 100644 --- a/src/functions/user/controllers/products/get.spec.ts +++ b/src/functions/user/controllers/products/get.spec.ts @@ -1,9 +1,9 @@ import { HttpRequest, InvocationContext } from "@azure/functions"; -import { CustomerProductServiceUpsert } from "~/functions/customer/services/product"; import { CustomerScheduleServiceCreate } from "~/functions/customer/services/schedule/create"; import { TimeUnit } from "~/functions/schedule"; +import { CustomerProductServiceUpsert } from "~/functions/customer/services/product/upsert"; import { HttpSuccessResponse, createContext, diff --git a/src/functions/user/controllers/products/list-by-schedule.spec.ts b/src/functions/user/controllers/products/list-by-schedule.spec.ts index 6b40d330..ea8ebd98 100644 --- a/src/functions/user/controllers/products/list-by-schedule.spec.ts +++ b/src/functions/user/controllers/products/list-by-schedule.spec.ts @@ -8,7 +8,7 @@ import { createHttpRequest, } from "~/library/jest/azure"; -import { CustomerProductServiceUpsert } from "~/functions/customer/services/product"; +import { CustomerProductServiceUpsert } from "~/functions/customer/services/product/upsert"; import { CustomerScheduleServiceCreate } from "~/functions/customer/services/schedule/create"; import { createUser } from "~/library/jest/helpers"; import { getProductObject } from "~/library/jest/helpers/product"; diff --git a/src/functions/user/controllers/products/list-by-schedule.ts b/src/functions/user/controllers/products/list-by-schedule.ts index 453130c9..0c5ad414 100644 --- a/src/functions/user/controllers/products/list-by-schedule.ts +++ b/src/functions/user/controllers/products/list-by-schedule.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { _ } from "~/library/handler"; -import { CustomerProductsServiceList } from "~/functions/customer/services/product"; +import { CustomerProductsServiceList } from "~/functions/customer/services/product/list"; import { UserServiceGetCustomerId } from "~/functions/user"; export type UserProductsControllerListByScheduleRequest = { diff --git a/src/functions/user/services/products/get.spec.ts b/src/functions/user/services/products/get.spec.ts index 68907bda..92c3c694 100644 --- a/src/functions/user/services/products/get.spec.ts +++ b/src/functions/user/services/products/get.spec.ts @@ -1,4 +1,4 @@ -import { CustomerProductServiceUpsert } from "~/functions/customer/services/product"; +import { CustomerProductServiceUpsert } from "~/functions/customer/services/product/upsert"; import { CustomerScheduleServiceCreate } from "~/functions/customer/services/schedule/create"; import { TimeUnit } from "~/functions/schedule"; import { createUser } from "~/library/jest/helpers"; diff --git a/src/functions/webhook-product.function.ts b/src/functions/webhook-product.function.ts new file mode 100644 index 00000000..eb49ed53 --- /dev/null +++ b/src/functions/webhook-product.function.ts @@ -0,0 +1,45 @@ +import "@shopify/shopify-api/adapters/node"; +import "module-alias/register"; + +import { + app, + HttpRequest, + HttpResponseInit, + InvocationContext, + output, +} from "@azure/functions"; +import { webhookProductProcess } from "./webhook/product/product"; +import { productUpdateSchema } from "./webhook/product/types"; + +export const productQueueName = "webhook-product"; +export const productQueueOutput = output.storageQueue({ + queueName: productQueueName, + connection: "QueueStorage", +}); + +app.storageQueue("webhookProductUpdateProcess", { + queueName: productQueueName, + connection: "QueueStorage", + handler: webhookProductProcess, +}); + +export async function webhookProduct( + request: HttpRequest, + context: InvocationContext +): Promise { + const body = await request.json(); + const parser = productUpdateSchema.safeParse(body); + if (parser.success) { + context.extraOutputs.set(productQueueOutput, parser.data); + context.log(`Started storageQueue with ID = '${productQueueName}'.`); + } + return { body: "Created queue item." }; +} + +app.http("webhookProductUpdate", { + methods: ["POST"], + authLevel: "anonymous", + route: "webhooks/product", + extraOutputs: [productQueueOutput], + handler: webhookProduct, +}); diff --git a/src/functions/webhook/product/product.dumb.ts b/src/functions/webhook/product/product.dumb.ts new file mode 100644 index 00000000..41683c93 --- /dev/null +++ b/src/functions/webhook/product/product.dumb.ts @@ -0,0 +1,46 @@ +import { ProductUpdateSchema } from "./types"; + +export const productDumbData: ProductUpdateSchema = { + admin_graphql_api_id: "gid://shopify/Product/802208864693", + handle: "borneklip-fra-6-ar", + id: 8022088646930, + title: "Børneklip (fra 6 år)", + variants: [ + { + admin_graphql_api_id: "gid://shopify/ProductVariant/46718525899079", + id: 46718525899079, + }, + { + admin_graphql_api_id: "gid://shopify/ProductVariant/46718525931847", + id: 46718525931847, + }, + { + admin_graphql_api_id: "gid://shopify/ProductVariant/46718525964615", + id: 46718525964615, + }, + { + admin_graphql_api_id: "gid://shopify/ProductVariant/46727191036231", + id: 46727191036231, + }, + { + admin_graphql_api_id: "gid://shopify/ProductVariant/49207559356743", + id: 49207559356743, + }, + { + admin_graphql_api_id: "gid://shopify/ProductVariant/49210092323143", + id: 49210092323143, + }, + { + admin_graphql_api_id: "gid://shopify/ProductVariant/49216099909959", + id: 49216099909959, + }, + { + admin_graphql_api_id: "gid://shopify/ProductVariant/49216108167495", + id: 49216108167495, + }, + { + admin_graphql_api_id: "gid://shopify/ProductVariant/49221362712903", + id: 49221362712903, + }, + ], +}; diff --git a/src/functions/webhook/product/product.ts b/src/functions/webhook/product/product.ts new file mode 100644 index 00000000..fa55fe92 --- /dev/null +++ b/src/functions/webhook/product/product.ts @@ -0,0 +1,63 @@ +import { InvocationContext } from "@azure/functions"; + +import { telemetryClient } from "~/library/application-insight"; +import { connect } from "~/library/mongoose"; +import { shopifyAdmin } from "~/library/shopify"; +import { ProductUpdateSchema } from "./types"; +import { ProductWebHookGetUnusedVariantIds } from "./unused"; + +export async function webhookProductProcess( + queueItem: unknown, + context: InvocationContext +) { + try { + await connect(); + const product = queueItem as ProductUpdateSchema; + const unusedVariantIds = await ProductWebHookGetUnusedVariantIds({ + product, + }); + + const { data } = await shopifyAdmin.request(MUTATION_DESTROY_VARIANTS, { + variables: { + productId: product.admin_graphql_api_id, + variantsIds: unusedVariantIds.map( + (l) => `gid://shopify/ProductVariant/${l}` + ), + }, + }); + + if (!data?.productVariantsBulkDelete?.product) { + context.error( + "webhook product error", + data?.productVariantsBulkDelete?.userErrors + ); + } + + context.log("webhook product success"); + } catch (exception: unknown) { + console.log(exception); + telemetryClient.trackException({ + exception: exception as Error, + }); + context.error( + `webhook order error ${(queueItem as any).order_id}`, + exception + ); + } +} + +const MUTATION_DESTROY_VARIANTS = `#graphql + mutation productVariantsBulkDelete($productId: ID!, $variantsIds: [ID!]!) { + productVariantsBulkDelete(productId: $productId, variantsIds: $variantsIds) { + product { + id + title + } + userErrors { + code + field + message + } + } + } +` as const; diff --git a/src/functions/webhook/product/types.ts b/src/functions/webhook/product/types.ts new file mode 100644 index 00000000..0d05c899 --- /dev/null +++ b/src/functions/webhook/product/types.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export const variantSchema = z.object({ + admin_graphql_api_id: z.string(), + id: z.number(), +}); + +export const productUpdateSchema = z.object({ + admin_graphql_api_id: z.string(), + handle: z.string(), + id: z.number(), + title: z.string(), + variants: z.array(variantSchema), +}); + +export type ProductUpdateSchema = z.infer; diff --git a/src/functions/webhook/product/unused.spec.ts b/src/functions/webhook/product/unused.spec.ts new file mode 100644 index 00000000..49286cb9 --- /dev/null +++ b/src/functions/webhook/product/unused.spec.ts @@ -0,0 +1,87 @@ +import { InvocationContext } from "@azure/functions"; +import { Schedule, ScheduleModel } from "~/functions/schedule"; +import { createContext } from "~/library/jest/azure"; +import { getProductObject } from "~/library/jest/helpers/product"; +import { productDumbData } from "./product.dumb"; +import { ProductWebHookGetUnusedVariantIds } from "./unused"; + +require("~/library/jest/mongoose/mongodb.jest"); + +jest.mock("~/library/application-insight", () => ({ + telemetryClient: { + trackException: jest.fn(), + }, +})); + +describe("webhookUpdateProcess", () => { + let context: InvocationContext = createContext(); + const schedule3: Omit = { + name: "schedula b", + customerId: 1, + slots: [ + { + day: "monday", + intervals: [ + { + from: "15:00", + to: "20:00", + }, + ], + }, + ], + products: [ + getProductObject({ + productId: productDumbData.id, //correct + variantId: productDumbData.variants[0].id, //correct + }), + getProductObject({ + productId: 1, + variantId: 49207559356743, + }), + ], + }; + + const schedule4: Omit = { + name: "schedule a", + customerId: 2, + slots: [ + { + day: "saturday", + intervals: [ + { + from: "17:00", + to: "20:00", + }, + ], + }, + ], + products: [ + getProductObject({ + productId: 1, + variantId: 1, + }), + getProductObject({ + productId: productDumbData.id, //correct + variantId: productDumbData.variants[1].id, //correct + }), + ], + }; + + beforeEach(async () => { + await ScheduleModel.create(schedule3); + await ScheduleModel.create(schedule4); + }); + + it("'should confirm that specific variant IDs are not in the array'", async () => { + let unusedVariantIds = await ProductWebHookGetUnusedVariantIds({ + product: productDumbData, + }); + const variantIdsToCheck = [ + productDumbData.variants[0].id, + productDumbData.variants[1].id, + ]; + variantIdsToCheck.forEach((id) => { + expect(unusedVariantIds).not.toContain(id); + }); + }); +}); diff --git a/src/functions/webhook/product/unused.ts b/src/functions/webhook/product/unused.ts new file mode 100644 index 00000000..e4831f9d --- /dev/null +++ b/src/functions/webhook/product/unused.ts @@ -0,0 +1,42 @@ +import { ScheduleModel } from "~/functions/schedule"; +import { ProductUpdateSchema } from "./types"; + +export async function ProductWebHookGetUnusedVariantIds({ + product, +}: { + product: ProductUpdateSchema; +}) { + let unusedVariantIds = product.variants.map((variant) => variant.id); + + const results = await ScheduleModel.aggregate([ + { + $match: { + "products.productId": product.id, + }, + }, + { + $unwind: "$products", + }, + { + $match: { + "products.productId": product.id, + "products.variantId": { $in: unusedVariantIds }, + }, + }, + { + $group: { + _id: "$products.productId", + variantIds: { $addToSet: "$products.variantId" }, + }, + }, + ]); + + if (results.length > 0) { + const variantIds = results[0].variantIds; + unusedVariantIds = unusedVariantIds.filter( + (id) => !variantIds.includes(id) + ); + } + + return unusedVariantIds; +} diff --git a/src/library/shopify/index.ts b/src/library/shopify/index.ts index 528ad3fb..b8520a30 100644 --- a/src/library/shopify/index.ts +++ b/src/library/shopify/index.ts @@ -1,22 +1,10 @@ -import { LATEST_API_VERSION, shopifyApi } from "@shopify/shopify-api"; -import "@shopify/shopify-api/adapters/node"; +import { createAdminApiClient } from "@shopify/admin-api-client"; /** - * Create Spi's Storefront client. + * Create Shopify Admin client. */ -const shopify = shopifyApi({ - apiKey: process.env["ShopifyApiKey"] || "", - apiSecretKey: process.env["ShopifyApiSecretKey"] || "", - adminApiAccessToken: process.env["ShopifyApiAccessToken"] || "", - apiVersion: LATEST_API_VERSION, - isCustomStoreApp: true, - scopes: [], - isEmbeddedApp: false, - hostName: process.env["ShopifyStoreDomain"] || "", -}); - -export const shopifyAdmin = new shopify.clients.Graphql({ - session: shopify.session.customAppSession( - process.env["ShopifyStoreDomain"] || "" - ), +export const shopifyAdmin = createAdminApiClient({ + storeDomain: process.env["ShopifyStoreDomain"] || "", + accessToken: process.env["ShopifyApiAccessToken"] || "", + apiVersion: "2023-10", }); diff --git a/src/types/admin.generated.d.ts b/src/types/admin.generated.d.ts index 203759a0..2f4fc613 100644 --- a/src/types/admin.generated.d.ts +++ b/src/types/admin.generated.d.ts @@ -1,105 +1,61 @@ /* eslint-disable eslint-comments/disable-enable-pair */ /* eslint-disable eslint-comments/no-unlimited-disable */ /* eslint-disable */ -import * as AdminTypes from "./admin.types"; - -export type ProductVariantCreateMutationVariables = AdminTypes.Exact<{ - input: AdminTypes.ProductVariantInput; -}>; - -export type ProductVariantCreateMutation = { - productVariantCreate?: AdminTypes.Maybe<{ - product?: AdminTypes.Maybe>; - productVariant?: AdminTypes.Maybe< - Pick< - AdminTypes.ProductVariant, - | "createdAt" - | "displayName" - | "id" - | "inventoryPolicy" - | "inventoryQuantity" - | "price" - | "title" - > & { product: Pick } - >; - userErrors: Array>; - }>; -}; +import * as AdminTypes from './admin.types'; export type FileCreateMutationVariables = AdminTypes.Exact<{ files: Array | AdminTypes.FileCreateInput; }>; -export type FileCreateMutation = { - fileCreate?: AdminTypes.Maybe<{ - files?: AdminTypes.Maybe< - Array< - | Pick - | Pick - | Pick - > - >; - userErrors: Array>; - }>; -}; + +export type FileCreateMutation = { fileCreate?: AdminTypes.Maybe<{ files?: AdminTypes.Maybe | Pick | Pick>>, userErrors: Array> }> }; export type FileGetQueryVariables = AdminTypes.Exact<{ - query: AdminTypes.Scalars["String"]["input"]; + query: AdminTypes.Scalars['String']['input']; }>; -export type FileGetQuery = { - files: { - nodes: Array<{ - preview?: AdminTypes.Maybe<{ - image?: AdminTypes.Maybe< - Pick - >; - }>; - }>; - }; -}; + +export type FileGetQuery = { files: { nodes: Array<{ preview?: AdminTypes.Maybe<{ image?: AdminTypes.Maybe> }> }> } }; export type StagedUploadsCreateMutationVariables = AdminTypes.Exact<{ input: Array | AdminTypes.StagedUploadInput; }>; -export type StagedUploadsCreateMutation = { - stagedUploadsCreate?: AdminTypes.Maybe<{ - stagedTargets?: AdminTypes.Maybe< - Array< - Pick & { - parameters: Array< - Pick - >; - } - > - >; - userErrors: Array>; - }>; -}; + +export type StagedUploadsCreateMutation = { stagedUploadsCreate?: AdminTypes.Maybe<{ stagedTargets?: AdminTypes.Maybe + & { parameters: Array> } + )>>, userErrors: Array> }> }; + +export type ProductVariantCreateMutationVariables = AdminTypes.Exact<{ + input: AdminTypes.ProductVariantInput; +}>; + + +export type ProductVariantCreateMutation = { productVariantCreate?: AdminTypes.Maybe<{ productVariant?: AdminTypes.Maybe<( + Pick + & { selectedOptions: Array> } + )>, userErrors: Array> }> }; + +export type ProductVariantsBulkDeleteMutationVariables = AdminTypes.Exact<{ + productId: AdminTypes.Scalars['ID']['input']; + variantsIds: Array | AdminTypes.Scalars['ID']['input']; +}>; + + +export type ProductVariantsBulkDeleteMutation = { productVariantsBulkDelete?: AdminTypes.Maybe<{ product?: AdminTypes.Maybe>, userErrors: Array> }> }; interface GeneratedQueryTypes { - "#graphql\n query FileGet($query: String!) {\n files(first: 10, sortKey: UPDATED_AT, reverse: true, query: $query) {\n nodes {\n preview {\n image {\n url\n width\n height\n }\n }\n }\n }\n }\n": { - return: FileGetQuery; - variables: FileGetQueryVariables; - }; + "#graphql\n query FileGet($query: String!) {\n files(first: 10, sortKey: UPDATED_AT, reverse: true, query: $query) {\n nodes {\n preview {\n image {\n url\n width\n height\n }\n }\n }\n }\n }\n": {return: FileGetQuery, variables: FileGetQueryVariables}, } interface GeneratedMutationTypes { - "#graphql\n mutation productVariantCreate($input: ProductVariantInput!) {\n productVariantCreate(input: $input) {\n product {\n id\n title\n }\n productVariant {\n createdAt\n displayName\n id\n inventoryPolicy\n inventoryQuantity\n price\n product {\n id\n }\n title\n }\n userErrors {\n field\n message\n }\n }\n }\n": { - return: ProductVariantCreateMutation; - variables: ProductVariantCreateMutationVariables; - }; - "#graphql\n mutation fileCreate($files: [FileCreateInput!]!) {\n fileCreate(files: $files) {\n files {\n fileStatus\n alt\n }\n userErrors {\n field\n message\n }\n }\n }\n": { - return: FileCreateMutation; - variables: FileCreateMutationVariables; - }; - "#graphql\n mutation stagedUploadsCreate($input: [StagedUploadInput!]!) {\n stagedUploadsCreate(input: $input) {\n stagedTargets {\n resourceUrl\n url\n parameters {\n name\n value\n }\n }\n userErrors {\n field\n message\n }\n }\n }\n": { - return: StagedUploadsCreateMutation; - variables: StagedUploadsCreateMutationVariables; - }; + "#graphql\n mutation fileCreate($files: [FileCreateInput!]!) {\n fileCreate(files: $files) {\n files {\n fileStatus\n alt\n }\n userErrors {\n field\n message\n }\n }\n }\n": {return: FileCreateMutation, variables: FileCreateMutationVariables}, + "#graphql\n mutation stagedUploadsCreate($input: [StagedUploadInput!]!) {\n stagedUploadsCreate(input: $input) {\n stagedTargets {\n resourceUrl\n url\n parameters {\n name\n value\n }\n }\n userErrors {\n field\n message\n }\n }\n }\n": {return: StagedUploadsCreateMutation, variables: StagedUploadsCreateMutationVariables}, + "#graphql\n mutation productVariantCreate($input: ProductVariantInput!) {\n productVariantCreate(input: $input) {\n productVariant {\n id\n title\n selectedOptions {\n name\n value\n }\n }\n userErrors {\n field\n message\n }\n }\n }\n": {return: ProductVariantCreateMutation, variables: ProductVariantCreateMutationVariables}, + "#graphql\n mutation productVariantsBulkDelete($productId: ID!, $variantsIds: [ID!]!) {\n productVariantsBulkDelete(productId: $productId, variantsIds: $variantsIds) {\n product {\n id\n title\n }\n userErrors {\n code\n field\n message\n }\n }\n }\n": {return: ProductVariantsBulkDeleteMutation, variables: ProductVariantsBulkDeleteMutationVariables}, } -declare module "@shopify/admin-api-client" { +declare module '@shopify/admin-api-client' { type InputMaybe = AdminTypes.InputMaybe; interface AdminQueries extends GeneratedQueryTypes {} interface AdminMutations extends GeneratedMutationTypes {}