├── .eslintrc.js ├── .gitignore ├── .pnp.js ├── LICENSE.md ├── README.md ├── jest.config.js ├── package.json ├── pm2.dev.config.js ├── src ├── api-ext │ ├── index.ts │ └── products │ │ ├── list-scheduled.gql │ │ ├── product-by-id.gql │ │ ├── products.controller.ts │ │ └── products.service.ts ├── api │ ├── access-scopes │ │ ├── access-scopes.controller.ts │ │ └── access-scopes.service.ts │ ├── api-cache.interceptor.ts │ ├── api-cache.interceptor.ts.changed │ ├── api.providers.ts │ ├── blogs │ │ ├── articles │ │ │ ├── articles.controller.spec.ts │ │ │ ├── articles.controller.ts │ │ │ ├── articles.service.spec.ts │ │ │ └── articles.service.ts │ │ ├── blogs.controller.spec.ts │ │ ├── blogs.controller.ts │ │ ├── blogs.service.spec.ts │ │ └── blogs.service.ts │ ├── checkouts │ │ └── checkouts.service.ts │ ├── collects │ │ ├── collects.controller.spec.ts │ │ ├── collects.controller.ts │ │ ├── collects.service.spec.ts │ │ └── collects.service.ts │ ├── custom-collections │ │ ├── custom-collections.controller.spec.ts │ │ ├── custom-collections.controller.ts │ │ ├── custom-collections.service.spec.ts │ │ └── custom-collections.service.ts │ ├── index.ts │ ├── interfaces │ │ ├── api-cache.ts │ │ ├── child-count.ts │ │ ├── child-get.ts │ │ ├── child-list.ts │ │ ├── index.ts │ │ ├── list-all-callback.ts │ │ ├── models │ │ │ ├── assets.ts │ │ │ ├── index.ts │ │ │ └── locales.ts │ │ ├── mongoose │ │ │ ├── access-scope.schema.ts │ │ │ ├── address.schema.ts │ │ │ ├── article.schema.ts │ │ │ ├── asset.schema.ts │ │ │ ├── blog.schema.ts │ │ │ ├── checkout.schema.ts │ │ │ ├── client-details.schema.ts │ │ │ ├── collect.schema.ts │ │ │ ├── collection-image.schema.ts │ │ │ ├── custom-collection.schema.ts │ │ │ ├── customer.schema.ts │ │ │ ├── discount-allocation.schema.ts │ │ │ ├── discount-application.schema.ts │ │ │ ├── discount-code.schema.ts │ │ │ ├── fulfillment.schema.ts │ │ │ ├── index.ts │ │ │ ├── line-item.schema.ts │ │ │ ├── location.schema.ts │ │ │ ├── metafield.schema.ts │ │ │ ├── note-attribute.schema.ts │ │ │ ├── order.schema.ts │ │ │ ├── page.schema.ts │ │ │ ├── payment-details.schema.ts │ │ │ ├── price-set.schema.ts │ │ │ ├── product-variant.schema.ts │ │ │ ├── product.schema.ts │ │ │ ├── refund.schema.ts │ │ │ ├── shipping-line.schema.ts │ │ │ ├── smart-collection.schema.ts │ │ │ ├── tax-line.schema.ts │ │ │ ├── theme.schema.ts │ │ │ └── transaction.schema.ts │ │ ├── options │ │ │ ├── article.ts │ │ │ ├── asset.ts │ │ │ ├── basic.ts │ │ │ ├── blog.ts │ │ │ ├── checkout.ts │ │ │ ├── collect.ts │ │ │ ├── custom-collection.ts │ │ │ ├── index.ts │ │ │ ├── locale.ts │ │ │ ├── order.ts │ │ │ ├── page.ts │ │ │ ├── product-variants.ts │ │ │ ├── product.ts │ │ │ ├── smart-collection.ts │ │ │ ├── sync.ts │ │ │ ├── theme.ts │ │ │ └── transactions.ts │ │ ├── root-count.ts │ │ ├── root-get.ts │ │ ├── root-list.ts │ │ └── shopify-base-object-type.ts │ ├── orders │ │ ├── orders.controller.spec.ts │ │ ├── orders.controller.ts │ │ ├── orders.service.spec.ts │ │ ├── orders.service.ts │ │ └── transactions │ │ │ ├── transactions.controller.spec.ts │ │ │ ├── transactions.controller.ts │ │ │ ├── transactions.service.spec.ts │ │ │ └── transactions.service.ts │ ├── pages │ │ ├── pages.controller.spec.ts │ │ ├── pages.controller.ts │ │ ├── pages.service.spec.ts │ │ └── pages.service.ts │ ├── products │ │ ├── product-variants │ │ │ └── product-variants.service.ts │ │ ├── products.controller.spec.ts │ │ ├── products.controller.ts │ │ ├── products.gateway.ts │ │ ├── products.service.spec.ts │ │ └── products.service.ts │ ├── search │ │ ├── search.controller.spec.ts │ │ ├── search.controller.ts │ │ ├── search.service.spec.ts │ │ └── search.service.ts │ ├── shopify-api-base.service.ts │ ├── shopify-api-child-countable.service.ts │ ├── shopify-api-child.service.ts │ ├── shopify-api-root-countable.service.ts │ ├── shopify-api-root.service.ts │ ├── smart-collections │ │ ├── smart-collections.controller.spec.ts │ │ ├── smart-collections.controller.ts │ │ ├── smart-collections.service.spec.ts │ │ └── smart-collections.service.ts │ ├── themes │ │ ├── assets │ │ │ ├── assets.controller.spec.ts │ │ │ ├── assets.controller.ts │ │ │ ├── assets.service.spec.ts │ │ │ └── assets.service.ts │ │ ├── locales │ │ │ ├── locales.controller.spec.ts │ │ │ ├── locales.controller.ts │ │ │ ├── locales.service.spec.ts │ │ │ └── locales.service.ts │ │ ├── themes.controller.spec.ts │ │ ├── themes.controller.ts │ │ ├── themes.service.spec.ts │ │ └── themes.service.ts │ └── webhooks │ │ └── webhooks.gateway.ts ├── auth │ ├── auth.controller.spec.ts │ ├── auth.controller.ts │ ├── auth.service.spec.ts │ ├── auth.service.ts │ ├── auth.strategy.ts │ ├── connect.providers.ts │ ├── connect.schema.ts │ ├── connect.service.ts │ ├── index.ts │ ├── interfaces │ │ ├── connect.ts │ │ ├── index.ts │ │ ├── profile.ts │ │ ├── request-type.ts │ │ └── role.ts │ └── passport.service.ts ├── charge │ ├── charge.controller.spec.ts │ ├── charge.controller.ts │ ├── charge.service.spec.ts │ ├── charge.service.ts │ └── interfaces │ │ ├── availableCharge.ts │ │ ├── index.ts │ │ └── plan.ts ├── debug.service.ts ├── event.service.ts ├── graphql-client.ts ├── guards │ ├── index.ts │ ├── request.decorator.ts │ ├── request.guard.ts │ ├── roles.decorator.ts │ ├── roles.guard.ts │ └── shopify-api.guard.ts ├── helpers │ ├── chunk-array.ts │ ├── delete-undefined-properties.ts │ ├── diff.ts │ ├── first-char-uppercase.ts │ ├── get-full-myshopify-domain.ts │ ├── get-subdomain.ts │ ├── index.ts │ ├── retry-helpers.ts │ └── underscore-case.ts ├── index.ts ├── interfaces │ ├── index.ts │ ├── mongoose │ │ └── sync-progress.schema.ts │ ├── resource.ts │ ├── session-socket.ts │ ├── session.ts │ ├── shopify-module-options.ts │ ├── sub-sync-progress-finished-callback.ts │ ├── sub-sync-progress.ts │ ├── sync-options.ts │ ├── sync-progress.ts │ ├── user-request.ts │ └── webhook.ts ├── main.ts ├── middlewares │ ├── body-parser-json.middleware.ts │ ├── body-parser-urlencoded.middleware.ts │ ├── get-shopify-connect.middleware.ts │ ├── get-user.middleware.ts │ ├── index.ts │ └── verify-webhook.middleware.ts ├── shop │ ├── interfaces │ │ ├── index.ts │ │ └── shop.ts │ ├── shop.controller.spec.ts │ ├── shop.controller.ts │ ├── shop.schema.ts │ ├── shop.service.spec.ts │ └── shop.service.ts ├── shopify.constants.ts ├── shopify.module.ts ├── socket │ ├── index.ts │ └── session-io.adapter.ts ├── sync │ ├── sync-providers.ts │ ├── sync.controller.spec.ts │ ├── sync.controller.ts │ ├── sync.gateway.ts │ ├── sync.service.spec.ts │ └── sync.service.ts └── webhooks │ ├── webhooks.controller.ts │ └── webhooks.service.ts ├── test └── e2e │ └── api │ └── products │ └── products.e2e-spec.ts └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | module.exports = { 3 | root: true, 4 | extends: ["@ribajs"], 5 | }; 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ormconfig.json 2 | node_modules/ 3 | dist/ 4 | .vscode/ 5 | 6 | # Configs 7 | src/config.service.ts 8 | test/config.*.ts 9 | 10 | # Yarn https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored 11 | .yarn/* 12 | !.yarn/releases 13 | !.yarn/plugins 14 | !.yarn/sdks 15 | !.yarn/versions 16 | .pnp.* 17 | # Ignore yarn.lock is not recommended, but be do this to be able to add this project as a yarn 2 submodule to other repositories 18 | yarn.lock 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nest Shopify Module 2 | 3 | ```bash 4 | npm install nest-shopify 5 | ``` 6 | 7 | ## Example 8 | 9 | See [ParcelLab Shopify App](https://github.com/ArtCodeStudio/parcel-lab-shopify-app) for a real application build with the Nest Shopify Module 10 | 11 | ## License 12 | 13 | nest-shopify is licensed under [The GNU Affero General Public License](LICENSE.md). 14 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: "node", 3 | moduleFileExtensions: [ 4 | "ts", 5 | "tsx", 6 | "js", 7 | "json" 8 | ], 9 | transform: { 10 | "^.+\\.tsx?$": "/node_modules/ts-jest/preprocessor.js" 11 | }, 12 | testRegex: "/src/.*\\.(test|spec).(ts|tsx|js)$", 13 | collectCoverageFrom: [ 14 | "src/**/*.{js,jsx,tsx,ts}", 15 | "!**/node_modules/**", 16 | "!**/vendor/**" 17 | ], 18 | coverageReporters: [ 19 | "json", 20 | "lcov" 21 | ] 22 | }; 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-shopify", 3 | "version": "8.0.0", 4 | "main": "dist/index.js", 5 | "module": "dist/index.js", 6 | "source": "src/index.ts", 7 | "types": "dist/index.d.ts", 8 | "description": "Nest module for Shopify app backend", 9 | "author": "Moritz Raguschat, Pascal Garber", 10 | "license": "AGPL", 11 | "url": "https://github.com/ArtCodeStudio/nestjs-shopify-module", 12 | "repository": { 13 | "type": "git", 14 | "url": "git@github.com:ArtCodeStudio/nestjs-shopify-module.git" 15 | }, 16 | "scripts": { 17 | "build": "npm run lint && tsc -p tsconfig.json", 18 | "clear": "rm -rf dist", 19 | "watch": "npm run build -- --watch", 20 | "lint": "eslint ./src --ext .js,.jsx,.ts,.tsx --ignore-pattern '*.spec.ts' --fix && npm run typecheck", 21 | "typecheck": "tsc -p tsconfig.json --noEmit", 22 | "link": "npm link && npm install && npm link shopify-admin-api && cd node_modules/shopify-admin-api && npm install", 23 | "test": "pnpify jest --config=jest.config.js", 24 | "test:watch": "jest --watch --config=jest.config.js", 25 | "test:coverage": "jest --config=jest.config.js --coverage --coverageDirectory=coverage", 26 | "test:product": "SWIFTYPE=1 MONGODB=1 jest --config=jest.config.js 'src/.*products.*\\.spec.ts$'", 27 | "test:product:watch": "SWIFTYPE=1 MONGODB=1 jest --watch --config=jest.config.js 'src/.*products.*\\.spec.ts$'", 28 | "test:sync": "SWIFTYPE=1 MONGODB=1 jest --config=jest.config.js 'src/sync.*\\.spec.ts$'", 29 | "test:sync:watch": "SWIFTYPE=1 MONGODB=1 jest --watch --config=jest.config.js 'src/sync.*\\.spec.ts$'" 30 | }, 31 | "files": [ 32 | "src/", 33 | "dist/" 34 | ], 35 | "dependencies": { 36 | "@graphql-tools/graphql-file-loader": "^7.3.3", 37 | "@graphql-tools/load": "^7.5.1", 38 | "@graphql-tools/utils": "^8.6.1", 39 | "@nestjs/common": "^8.2.5", 40 | "@nestjs/core": "^8.2.5", 41 | "@nestjs/passport": "^8.1.0", 42 | "@nestjs/platform-express": "^8.2.5", 43 | "@nestjs/platform-socket.io": "^8.2.5", 44 | "@nestjs/websockets": "^8.2.5", 45 | "@types/better-sqlite3": "^7.6.9", 46 | "@types/find-root": "^1.1.2", 47 | "@yarnpkg/pnpify": "^3.1.1-rc.10", 48 | "better-sqlite3": "^9.3.0", 49 | "better-sqlite3-session-store": "^0.1.0", 50 | "body-parser": "^1.19.1", 51 | "cache-manager": "^3.6.0", 52 | "cache-manager-ioredis": "^2.1.0", 53 | "cache-manager-redis": "^0.6.0", 54 | "concat-stream": "^2.0.0", 55 | "debug": "^4.3.3", 56 | "deepmerge": "^4.2.2", 57 | "express-socket.io-session": "^1.3.5", 58 | "find-root": "^1.1.0", 59 | "graphql": "^16.2.0", 60 | "graphql-request": "^3.7.0", 61 | "ioredis": "^4.28.3", 62 | "mongoose": "8.9.5", 63 | "node-fetch": "^2.7.0", 64 | "p-map": "^4.0.0", 65 | "p-queue": "^6.6.2", 66 | "p-retry": "^4.6.1", 67 | "passport": "^0.5.2", 68 | "passport-shopify": "^0.1.2", 69 | "reflect-metadata": "^0.1.13", 70 | "rxjs": "^7.5.2", 71 | "shopify-admin-api": "workspace:*", 72 | "shopify-token": "^4.0.4", 73 | "socket.io": "^4.4.1", 74 | "socket.io-adapter": "^2.3.3", 75 | "socket.io-redis": "^6.1.1", 76 | "typescript": "4.4.4" 77 | }, 78 | "devDependencies": { 79 | "@nestjs/testing": "8.2.5", 80 | "@ribajs/eslint-config": "workspace:*", 81 | "@types/body-parser": "^1.19.2", 82 | "@types/cache-manager": "^3.4.2", 83 | "@types/cache-manager-ioredis": "^2.0.2", 84 | "@types/concat-stream": "^1.6.1", 85 | "@types/debug": "^4.1.7", 86 | "@types/deepmerge": "^2.2.0", 87 | "@types/elasticsearch": "^5.0.40", 88 | "@types/express": "^4.17.13", 89 | "@types/express-serve-static-core": "^4.17.28", 90 | "@types/express-session": "^1.17.4", 91 | "@types/express-socket.io-session": "^1.3.6", 92 | "@types/ioredis": "^4.28.7", 93 | "@types/jest": "^27.4.0", 94 | "@types/mongodb": "^3.6.20", 95 | "@types/node": "^16.11.19", 96 | "@types/node-fetch": "^2.6.11", 97 | "@types/passport": "^1.0.7", 98 | "@types/retry": "^0.12.1", 99 | "@types/socket.io": "^3.0.2", 100 | "@types/supertest": "^2.0.11", 101 | "@typescript-eslint/eslint-plugin": "^5.9.1", 102 | "@typescript-eslint/parser": "^5.9.1", 103 | "eslint": "^8.6.0", 104 | "eslint-config-prettier": "^8.3.0", 105 | "eslint-plugin-prettier": "^4.0.0", 106 | "jest": "^27.4.7", 107 | "prettier": "^2.5.1", 108 | "supertest": "^6.2.1", 109 | "ts-jest": "^27.1.2", 110 | "ts-node": "^10.4.0" 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /pm2.dev.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps : [{ 3 | name : "nsm:watch", 4 | script : "yarn run watch", 5 | watch : false, 6 | instances: 1, 7 | env: { 8 | "NODE_ENV": "development", 9 | "DEBUG": "" 10 | } 11 | }] 12 | }; 13 | -------------------------------------------------------------------------------- /src/api-ext/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./products/products.service"; 2 | export * from "./products/products.controller"; 3 | -------------------------------------------------------------------------------- /src/api-ext/products/list-scheduled.gql: -------------------------------------------------------------------------------- 1 | query listScheduled($first: Int!, $query: String!, $reverse: Boolean!, $sortKey: ProductSortKeys!, $after: String) { 2 | products(query:$query, first:$first, reverse:$reverse, sortKey:$sortKey, after:$after) { 3 | edges { 4 | node { 5 | legacyResourceId 6 | handle 7 | title 8 | tags 9 | priceRangeV2 { 10 | minVariantPrice { 11 | amount 12 | currencyCode 13 | } 14 | } 15 | media(first: 2) { 16 | edges { 17 | node { 18 | preview { 19 | image { 20 | smallImage: transformedSrc(maxWidth: 370, maxHeight: 555, scale: 1) 21 | bigImage: transformedSrc(maxWidth: 740, maxHeight: 1110, scale: 1) 22 | } 23 | } 24 | alt 25 | } 26 | } 27 | } 28 | } 29 | cursor 30 | } 31 | pageInfo { 32 | hasNextPage 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /src/api-ext/products/product-by-id.gql: -------------------------------------------------------------------------------- 1 | query productById($id: ID!) { 2 | product(id: $id) { 3 | onlineStorePreviewUrl 4 | publishedAt 5 | handle 6 | } 7 | } 8 | 9 | -------------------------------------------------------------------------------- /src/api-ext/products/products.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | UseGuards, 4 | UseInterceptors, 5 | CacheTTL, 6 | Req, 7 | Get, 8 | Query, 9 | } from "@nestjs/common"; 10 | import { Roles } from "../../guards/roles.decorator"; 11 | import { ExtProductsService } from "./products.service"; 12 | import { DebugService } from "../../debug.service"; 13 | import { ShopifyApiGuard } from "../../guards/shopify-api.guard"; 14 | import { IUserRequest } from "../../interfaces/user-request"; 15 | import { RolesGuard } from "../../guards/roles.guard"; 16 | import { SortKey } from "./products.service"; 17 | import { ApiCacheInterceptor } from "../../api/api-cache.interceptor"; 18 | 19 | @Controller("shopify/api-ext/products") 20 | @UseInterceptors(ApiCacheInterceptor) 21 | export class ExtProductsController { 22 | constructor(protected readonly extProductsService: ExtProductsService) {} 23 | 24 | logger = new DebugService(`shopify:${this.constructor.name}`); 25 | 26 | /** 27 | * Get a list of all publications 28 | * @param req 29 | * @param res 30 | * @param themeId 31 | */ 32 | @UseGuards(ShopifyApiGuard, RolesGuard) 33 | @Roles() // Also allowed from shop frontend 34 | @CacheTTL(300) 35 | @Get("scheduled") 36 | async listScheduled( 37 | @Req() req: IUserRequest, 38 | @Query("tag") tag = "*", 39 | @Query("limit") limit = 50, 40 | @Query("after") after, 41 | @Query("sortKey") sortKey = "ID", 42 | @Query("reverse") reverse = false 43 | ) { 44 | try { 45 | const products = await this.extProductsService.listScheduled( 46 | req.session[`shopify-connect-${req.shop}`], 47 | { 48 | limit: limit, 49 | tag: tag, 50 | sortKey: SortKey[sortKey as keyof typeof SortKey], 51 | reverse: reverse, 52 | after: after, 53 | } 54 | ); 55 | return products; 56 | } catch (error) { 57 | return error; 58 | } 59 | } 60 | 61 | /** 62 | * Get a list of all publications 63 | * @param req 64 | * @param res 65 | * @param themeId 66 | */ 67 | @UseGuards(ShopifyApiGuard, RolesGuard) 68 | @Roles() // Also allowed from shop frontend 69 | @Get("preview") 70 | @CacheTTL(1800) 71 | async getPreview(@Req() req: IUserRequest, @Query("id") id) { 72 | const products = await this.extProductsService.getPreview( 73 | req.session[`shopify-connect-${req.shop}`], 74 | { 75 | id, 76 | } 77 | ); 78 | return products; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/api-ext/products/products.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from "@nestjs/common"; 2 | import { IShopifyConnect } from "../../auth/interfaces/connect"; 3 | import { ShopifyModuleOptions } from "../../interfaces"; 4 | import { SHOPIFY_MODULE_OPTIONS } from "../../shopify.constants"; 5 | import { DebugService } from "../../debug.service"; 6 | import { GraphQLClient } from "../../graphql-client"; 7 | 8 | //TODO outsource 9 | export enum SortKey { 10 | VENDOR = "VENDOR", 11 | ID = "ID", 12 | INVENTORY_TOTAL = "INVENTORY_TOTAL", 13 | PRODUCT_TYPE = "PRODUCT_TYPE", 14 | PUBLISHED_AT = "PUBLISHED_AT", 15 | RELEVANCE = "RELEVANCE", 16 | TITLE = "TITLE", 17 | UPDATED_AT = "UPDATED_AT", 18 | CREATED_AT = "CREATED_AT", 19 | } 20 | 21 | @Injectable() 22 | export class ExtProductsService { 23 | logger = new DebugService(`shopify:${this.constructor.name}`); 24 | 25 | constructor( 26 | @Inject(SHOPIFY_MODULE_OPTIONS) 27 | protected readonly shopifyModuleOptions: ShopifyModuleOptions 28 | ) {} 29 | 30 | async listScheduled( 31 | user: IShopifyConnect, 32 | options: { 33 | limit: number; 34 | tag: string; 35 | sortKey: SortKey; 36 | reverse: boolean; 37 | after?: string; 38 | } 39 | ) { 40 | const graphQLClient = new GraphQLClient( 41 | user.myshopify_domain, 42 | user.accessToken 43 | ); 44 | const result = await graphQLClient.execute( 45 | "src/api-ext/products/list-scheduled.gql", 46 | { 47 | first: Number(options.limit), 48 | query: `tag:"${options.tag}" AND status:"ACTIVE" AND published_status:"online_store:hidden" AND publishedAt:NULL`, 49 | sortKey: options.sortKey, 50 | reverse: JSON.parse(options.reverse as unknown as string), 51 | after: options.after, 52 | } 53 | ); 54 | return result; 55 | } 56 | 57 | async getPreview( 58 | user: IShopifyConnect, 59 | options: { 60 | id: number; 61 | } 62 | ) { 63 | const graphQLClient = new GraphQLClient( 64 | user.myshopify_domain, 65 | user.accessToken 66 | ); 67 | const result = await graphQLClient.execute( 68 | "src/api-ext/products/product-by-id.gql", 69 | { 70 | id: "gid://shopify/Product/" + options.id, 71 | } 72 | ); 73 | this.logger.debug("preview result", result); 74 | return result; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/api/access-scopes/access-scopes.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | UseGuards, 4 | Req, 5 | Get, 6 | HttpStatus, 7 | HttpException, 8 | } from "@nestjs/common"; 9 | import { AccessScopesService } from "./access-scopes.service"; 10 | import { DebugService } from "../../debug.service"; 11 | 12 | import { ShopifyApiGuard } from "../../guards/shopify-api.guard"; 13 | import { Roles } from "../../guards/roles.decorator"; 14 | 15 | // Interfaces 16 | import { IUserRequest } from "../../interfaces/user-request"; 17 | 18 | @Controller("shopify/api/access-scopes") 19 | export class AccessScopesController { 20 | constructor(protected readonly accessScopesService: AccessScopesService) {} 21 | logger = new DebugService(`shopify:${this.constructor.name}`); 22 | 23 | @UseGuards(ShopifyApiGuard) 24 | @Roles("shopify-staff-member") 25 | @Get() 26 | async listFromShopify(@Req() req: IUserRequest) { 27 | try { 28 | return await this.accessScopesService.listFromShopify( 29 | req.session[`shopify-connect-${req.shop}`] 30 | ); 31 | } catch (error) { 32 | this.logger.error(error); 33 | throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/api/access-scopes/access-scopes.service.ts: -------------------------------------------------------------------------------- 1 | // nest 2 | import { Inject, Injectable } from "@nestjs/common"; 3 | 4 | // Third party 5 | import { shopifyRetry } from "../../helpers"; 6 | 7 | import { IShopifyConnect } from "../../auth/interfaces"; 8 | import { AccessScopes } from "shopify-admin-api"; 9 | import { Interfaces } from "shopify-admin-api"; 10 | import { Model } from "mongoose"; 11 | import { AccessScopeDocument } from "../interfaces"; 12 | 13 | import { EventService } from "../../event.service"; 14 | import { Resource } from "../../interfaces"; 15 | 16 | @Injectable() 17 | export class AccessScopesService { 18 | resourceName: Resource = "accessScopes"; 19 | subResourceNames: Resource[] = []; 20 | 21 | constructor( 22 | @Inject("AccessScopeModelToken") 23 | protected readonly accessScopeModel: ( 24 | shopName: string 25 | ) => Model, 26 | protected readonly eventService: EventService 27 | ) {} 28 | 29 | /** 30 | * Retrieves a list of `ShopifyObjectType[]` directly from shopify. 31 | * @param user 32 | * @param options 33 | */ 34 | public async listFromShopify( 35 | shopifyConnect: IShopifyConnect 36 | ): Promise[]> { 37 | const shopifyAccessScopeModel = new AccessScopes( 38 | shopifyConnect.myshopify_domain, 39 | shopifyConnect.accessToken 40 | ); 41 | return shopifyRetry(() => { 42 | return shopifyAccessScopeModel.list(); 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/api/api-cache.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Observable, of } from "rxjs"; 2 | import { tap } from "rxjs/operators"; 3 | import { 4 | Inject, 5 | Injectable, 6 | Optional, 7 | ExecutionContext, 8 | CallHandler, 9 | HttpServer, 10 | NestInterceptor, 11 | CACHE_KEY_METADATA, 12 | CACHE_MANAGER, 13 | CACHE_TTL_METADATA, 14 | } from "@nestjs/common"; 15 | 16 | import { DebugService } from "../debug.service"; 17 | import { ShopifyAuthService } from "../auth/auth.service"; 18 | 19 | const HTTP_ADAPTER_HOST = "HttpAdapterHost"; 20 | const REFLECTOR = "Reflector"; 21 | 22 | const isNil = (obj: any): obj is null | undefined => 23 | typeof obj === "undefined" || obj === null; 24 | 25 | export interface HttpAdapterHost { 26 | httpAdapter: T; 27 | } 28 | 29 | @Injectable() 30 | export class ApiCacheInterceptor implements NestInterceptor { 31 | @Optional() 32 | @Inject(HTTP_ADAPTER_HOST) 33 | protected readonly httpAdapterHost: HttpAdapterHost; 34 | 35 | constructor( 36 | @Inject(CACHE_MANAGER) protected readonly cacheManager: any, 37 | @Inject(REFLECTOR) protected readonly reflector: any, 38 | private readonly shopifyAuthService: ShopifyAuthService 39 | ) {} 40 | 41 | logger = new DebugService(`shopify:ApiCacheInterceptor`); 42 | 43 | async intercept( 44 | context: ExecutionContext, 45 | next: CallHandler 46 | ): Promise> { 47 | const key = await this.trackBy(context); 48 | const ttlValueOrFactory = 49 | this.reflector.get(CACHE_TTL_METADATA, context.getHandler()) || null; 50 | 51 | if (!key) { 52 | return next.handle(); 53 | } 54 | try { 55 | const value = await this.cacheManager.get(key); 56 | if (!isNil(value)) { 57 | this.logger.debug(`cache hit for ${key}`); 58 | return of(value); 59 | } 60 | this.logger.debug(`cache miss for ${key}`); 61 | const ttl = 62 | typeof ttlValueOrFactory === "function" 63 | ? await ttlValueOrFactory(context) 64 | : ttlValueOrFactory; 65 | return next.handle().pipe( 66 | tap((response) => { 67 | if ( 68 | !(response["response"] !== undefined && response["response"].errors) 69 | ) { 70 | const args = isNil(ttl) 71 | ? [key, response] 72 | : [key, response, { ttl }]; 73 | this.cacheManager.set(...args); 74 | } 75 | }) 76 | ); 77 | } catch (error) { 78 | this.logger.error(`cache error for ${key}`, error); 79 | return next.handle(); 80 | } 81 | } 82 | 83 | async trackBy(context: ExecutionContext): Promise { 84 | const httpAdapter = this.httpAdapterHost.httpAdapter; 85 | const isHttpApp = httpAdapter && !!httpAdapter.getRequestMethod; 86 | const cacheMetadata = this.reflector.get( 87 | CACHE_KEY_METADATA, 88 | context.getHandler() 89 | ); 90 | 91 | if (!isHttpApp || cacheMetadata) { 92 | return cacheMetadata; 93 | } 94 | 95 | const request = context.getArgByIndex(0); 96 | if (httpAdapter.getRequestMethod(request) !== "GET") { 97 | return undefined; 98 | } 99 | let key = httpAdapter.getRequestUrl(request); 100 | 101 | const shop = 102 | await this.shopifyAuthService.getMyShopifyDomainSecureForThemeClients( 103 | request 104 | ); 105 | key = `${shop}:${key}`; 106 | this.logger.debug(`trackBy cache by ${key}`); 107 | return key; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/api/api-cache.interceptor.ts.changed: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, Optional, NestInterceptor, HttpServer, ExecutionContext, CACHE_MANAGER } from '@nestjs/common'; 2 | const CACHE_KEY_METADATA = 'cache_module:cache_key'; 3 | import { DebugService } from 'debug.service'; 4 | import { ShopifyModuleOptions } from '../interfaces/shopify-module-options'; 5 | import { SHOPIFY_MODULE_OPTIONS } from '../shopify.constants'; 6 | 7 | 8 | import { Observable, of } from 'rxjs'; 9 | import { tap } from 'rxjs/operators'; 10 | 11 | // NOTE (external) 12 | // We need to deduplicate them here due to the circular dependency 13 | // between core and common packages 14 | const HTTP_SERVER_REF = 'HTTP_SERVER_REF'; 15 | const REFLECTOR = 'Reflector'; 16 | 17 | @Injectable() 18 | export class ApiCacheInterceptor implements NestInterceptor { 19 | protected readonly isHttpApp: boolean; 20 | 21 | constructor( 22 | @Inject(SHOPIFY_MODULE_OPTIONS) 23 | private readonly shopifyModuleOptions: ShopifyModuleOptions, 24 | @Optional() 25 | @Inject(HTTP_SERVER_REF) 26 | protected readonly httpServer: HttpServer, 27 | @Inject(CACHE_MANAGER) protected readonly cacheManager: any, 28 | @Inject(REFLECTOR) protected readonly reflector, 29 | ) { 30 | this.isHttpApp = httpServer && !!httpServer.getRequestMethod; 31 | } 32 | 33 | async intercept( 34 | context: ExecutionContext, 35 | call$: Observable, 36 | ): Promise> { 37 | const key = this.trackBy(context); 38 | if (!key) { 39 | return call$; 40 | } 41 | try { 42 | const value = await this.cacheManager.get(key); 43 | if (value) { 44 | return of(value); 45 | } 46 | return call$.pipe(tap(response => this.cacheManager.set(key, response))); 47 | } catch { 48 | return call$; 49 | } 50 | } 51 | 52 | logger = new DebugService(`shopify:ApiCacheInterceptor`); 53 | 54 | // TODO move to utils 55 | getClientHost(request) { 56 | let host; 57 | if ((request.headers as any).origin) { 58 | // request from shopify theme 59 | host = (request.headers as any).origin.split('://')[1]; 60 | } else { 61 | // request from app backend 62 | host = (request.headers as any).host; 63 | } 64 | return host; 65 | } 66 | 67 | // TODO move to utils 68 | isLoggedIn(request) { 69 | this.logger.debug('isLoggedIn', request.user); 70 | if (request.user !== null && typeof request.user === 'object') { 71 | return true; 72 | } 73 | return false; 74 | } 75 | 76 | private superTrackBy(context: ExecutionContext): string | undefined { 77 | if (!this.isHttpApp) { 78 | return this.reflector.get(CACHE_KEY_METADATA, context.getHandler()); 79 | } 80 | const request = context.getArgByIndex(0); 81 | if (this.httpServer.getRequestMethod(request) !== 'GET') { 82 | return undefined; 83 | } 84 | return this.httpServer.getRequestUrl(request); 85 | } 86 | 87 | /** 88 | * Cache by url and host 89 | * @param context 90 | */ 91 | trackBy(context: ExecutionContext): string | undefined { 92 | const request = context.getArgByIndex(0); 93 | let key = this.superTrackBy(context); 94 | this.logger.debug(`trackBy check key ${key}`); 95 | if (!key) { 96 | return undefined; 97 | } 98 | let host = this.getClientHost(request); 99 | if (host === this.shopifyModuleOptions.appHost) { 100 | // this.logger.debug(`request from backend`, request.user); 101 | 102 | // Do not cache if no user is logged in 103 | if (!this.isLoggedIn(request)) { 104 | return undefined; 105 | } 106 | 107 | host = request.user.shop.domain; 108 | } 109 | key = `${host}:${key}`; 110 | this.logger.debug(`trackBy cache by ${key}`); 111 | return key; 112 | } 113 | } 114 | 115 | 116 | 117 | 118 | -------------------------------------------------------------------------------- /src/api/blogs/articles/articles.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ArticlesController } from './articles.controller'; 3 | 4 | import { ShopifyModule } from '../../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('Articles Controller', () => { 9 | let module: TestingModule; 10 | beforeAll(async () => { 11 | module = await Test.createTestingModule({ 12 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 13 | }).compile(); 14 | }); 15 | it('should be defined', () => { 16 | const controller: ArticlesController = module.get(ArticlesController); 17 | expect(controller).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/api/blogs/articles/articles.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { ArticlesService } from './articles.service'; 3 | 4 | import { ShopifyModule } from '../../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('ArticlesService', () => { 9 | let service: ArticlesService; 10 | beforeAll(async () => { 11 | const module = await Test.createTestingModule({ 12 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 13 | }).compile(); 14 | service = module.get(ArticlesService); 15 | }); 16 | it('should be defined', () => { 17 | expect(service).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/api/blogs/blogs.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { BlogsController } from './blogs.controller'; 3 | 4 | import { ShopifyModule } from '../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('Blogs Controller', () => { 9 | let module: TestingModule; 10 | beforeAll(async () => { 11 | module = await Test.createTestingModule({ 12 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 13 | }).compile(); 14 | }); 15 | it('should be defined', () => { 16 | const controller: BlogsController = module.get(BlogsController); 17 | expect(controller).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/api/blogs/blogs.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { BlogsService } from './blogs.service'; 3 | 4 | import { ShopifyModule } from '../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('BlogsService', () => { 9 | let service: BlogsService; 10 | beforeAll(async () => { 11 | const module = await Test.createTestingModule({ 12 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 13 | }).compile(); 14 | service = module.get(BlogsService); 15 | }); 16 | it('should be defined', () => { 17 | expect(service).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/api/checkouts/checkouts.service.ts: -------------------------------------------------------------------------------- 1 | // nest 2 | import { Inject, Injectable } from "@nestjs/common"; 3 | 4 | // Third party 5 | import { shopifyRetry } from "../../helpers"; 6 | 7 | import { IShopifyConnect } from "../../auth/interfaces"; 8 | import { Checkouts } from "shopify-admin-api"; 9 | import { Interfaces } from "shopify-admin-api"; 10 | import { Model } from "mongoose"; 11 | import { 12 | CheckoutDocument, 13 | IShopifySyncCheckoutGetOptions, 14 | IShopifySyncCheckoutListOptions, 15 | } from "../interfaces"; 16 | 17 | import { EventService } from "../../event.service"; 18 | import { Resource } from "../../interfaces"; 19 | 20 | @Injectable() 21 | export class CheckoutsService { 22 | resourceName: Resource = "products"; 23 | subResourceNames: Resource[] = []; 24 | 25 | constructor( 26 | @Inject("CheckoutModelToken") 27 | protected readonly checkoutModel: ( 28 | shopName: string 29 | ) => Model, 30 | protected readonly eventService: EventService 31 | ) {} 32 | 33 | /** 34 | * Retrieves a single `ShopifyObjectType[]` directly from the shopify API 35 | * @param user 36 | * @param id 37 | * @param sync 38 | * @see https://help.shopify.com/en/api/reference/products/product#show 39 | */ 40 | public async getFromShopify( 41 | user: IShopifyConnect, 42 | checkoutToken: string, 43 | options?: IShopifySyncCheckoutGetOptions 44 | ): Promise | null> { 45 | const shopifyCheckoutModel = new Checkouts( 46 | user.myshopify_domain, 47 | user.accessToken 48 | ); 49 | delete options.syncToDb; 50 | return shopifyRetry(() => { 51 | return shopifyCheckoutModel.get(checkoutToken, options); 52 | }); 53 | } 54 | 55 | /** 56 | * Retrieves a list of `ShopifyObjectType[]` directly from shopify. 57 | * @param user 58 | * @param options 59 | */ 60 | public async listFromShopify( 61 | shopifyConnect: IShopifyConnect, 62 | options?: IShopifySyncCheckoutListOptions 63 | ): Promise[]> { 64 | const shopifyCheckoutModel = new Checkouts( 65 | shopifyConnect.myshopify_domain, 66 | shopifyConnect.accessToken 67 | ); 68 | options = Object.assign({}, options); 69 | delete options.syncToDb; 70 | delete options.failOnSyncError; 71 | delete options.cancelSignal; // TODO@Moritz? 72 | return shopifyRetry(() => { 73 | return shopifyCheckoutModel.list(options); 74 | }); 75 | } 76 | 77 | /** 78 | * Retrieves a list of product variants from the app's mongodb database. 79 | * @param user 80 | */ 81 | public async listFromDb(/*user: IShopifyConnect, options: IAppCheckoutListOptions = {}*/): Promise< 82 | Interfaces.Checkout[] 83 | > { 84 | return null; // super.listFromDb(user, query, basicOptions); 85 | } 86 | 87 | /** 88 | * Creates a new product variant directly in shopify. 89 | * @param user 90 | * @param productId 91 | */ 92 | public async createInShopify( 93 | user: IShopifyConnect, 94 | checkout: Interfaces.Checkout 95 | ): Promise { 96 | const shopifyCheckoutModel = new Checkouts( 97 | user.myshopify_domain, 98 | user.accessToken 99 | ); 100 | return shopifyRetry(() => shopifyCheckoutModel.create(checkout)); 101 | } 102 | 103 | /** 104 | * Updates a product variant directly in shopify. 105 | * @param user 106 | * @param id 107 | * @param product 108 | */ 109 | public async updateInShopify( 110 | user: IShopifyConnect, 111 | checkoutToken: string, 112 | checkout: Interfaces.CheckoutUpdate 113 | ): Promise { 114 | const shopifyCheckoutModel = new Checkouts( 115 | user.myshopify_domain, 116 | user.accessToken 117 | ); 118 | return shopifyRetry(() => 119 | shopifyCheckoutModel.update(checkoutToken, checkout) 120 | ); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/api/collects/collects.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { CollectsController } from './collects.controller'; 3 | 4 | describe('Collects Controller', () => { 5 | let module: TestingModule; 6 | 7 | beforeAll(async () => { 8 | module = await Test.createTestingModule({ 9 | controllers: [CollectsController], 10 | }).compile(); 11 | }); 12 | it('should be defined', () => { 13 | const controller: CollectsController = module.get(CollectsController); 14 | expect(controller).toBeDefined(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/api/collects/collects.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from "@nestjs/common"; 2 | 3 | @Controller("collects") 4 | export class CollectsController {} 5 | -------------------------------------------------------------------------------- /src/api/collects/collects.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { CollectsService } from './collects.service'; 3 | 4 | describe('CollectsService', () => { 5 | let service: CollectsService; 6 | 7 | beforeAll(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [CollectsService], 10 | }).compile(); 11 | service = module.get(CollectsService); 12 | }); 13 | it('should be defined', () => { 14 | expect(service).toBeDefined(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/api/collects/collects.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from "@nestjs/common"; 2 | import { EventService } from "../../event.service"; 3 | import { ShopifyApiRootCountableService } from "../shopify-api-root-countable.service"; 4 | 5 | // Interfaces 6 | import { Model } from "mongoose"; 7 | import { Collects, Interfaces } from "shopify-admin-api"; 8 | import { 9 | CollectDocument, 10 | IShopifySyncCollectCountOptions, 11 | IShopifySyncCollectGetOptions, 12 | IShopifySyncCollectListOptions, 13 | } from "../interfaces"; 14 | import { 15 | SyncProgressDocument, 16 | Resource, 17 | ShopifyModuleOptions, 18 | } from "../../interfaces"; 19 | import { SHOPIFY_MODULE_OPTIONS } from "../../shopify.constants"; 20 | 21 | @Injectable() 22 | export class CollectsService extends ShopifyApiRootCountableService< 23 | Interfaces.Collect, // ShopifyObjectType 24 | Collects, // ShopifyModelClass 25 | IShopifySyncCollectCountOptions, // CountOptions 26 | IShopifySyncCollectGetOptions, // GetOptions 27 | IShopifySyncCollectListOptions, // ListOptions 28 | CollectDocument // DatabaseDocumentType 29 | > { 30 | resourceName: Resource = "collects"; 31 | subResourceNames: Resource[] = []; 32 | 33 | constructor( 34 | @Inject("CollectModelToken") 35 | private readonly collectModel: (shopName: string) => Model, 36 | @Inject("SyncProgressModelToken") 37 | private readonly syncProgressModel: Model, 38 | protected readonly eventService: EventService, 39 | @Inject(SHOPIFY_MODULE_OPTIONS) 40 | protected readonly shopifyModuleOptions: ShopifyModuleOptions 41 | ) { 42 | super( 43 | collectModel, 44 | Collects, 45 | eventService, 46 | syncProgressModel, 47 | shopifyModuleOptions 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/api/custom-collections/custom-collections.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { CustomCollectionsController } from './custom-collections.controller'; 3 | 4 | import { ShopifyModule } from '../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('CustomCollections Controller', () => { 9 | let module: TestingModule; 10 | 11 | beforeAll(async () => { 12 | module = await Test.createTestingModule({ 13 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 14 | }).compile(); 15 | }); 16 | it('should be defined', () => { 17 | const controller: CustomCollectionsController = module.get(CustomCollectionsController); 18 | expect(controller).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/api/custom-collections/custom-collections.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { CustomCollectionsService } from './custom-collections.service'; 3 | 4 | import { ShopifyModule } from '../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('CustomCollectionsService', () => { 9 | let service: CustomCollectionsService; 10 | 11 | beforeAll(async () => { 12 | const module = await Test.createTestingModule({ 13 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 14 | }).compile(); 15 | service = module.get(CustomCollectionsService); 16 | }); 17 | it('should be defined', () => { 18 | expect(service).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/api/custom-collections/custom-collections.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from "@nestjs/common"; 2 | import { CustomCollections, Interfaces } from "shopify-admin-api"; // https://github.com/ArtCodeStudio/shopify-admin-api 3 | import { 4 | SyncProgressDocument, 5 | Resource, 6 | ShopifyModuleOptions, 7 | } from "../../interfaces"; 8 | import { Model } from "mongoose"; 9 | import { EventService } from "../../event.service"; 10 | import { ShopifyApiRootCountableService } from "../shopify-api-root-countable.service"; 11 | import { SHOPIFY_MODULE_OPTIONS } from "../../shopify.constants"; 12 | 13 | import { 14 | CustomCollectionDocument, 15 | IShopifySyncCustomCollectionCountOptions, 16 | IShopifySyncCustomCollectionGetOptions, 17 | IShopifySyncCustomCollectionListOptions, 18 | } from "../interfaces"; 19 | 20 | @Injectable() 21 | export class CustomCollectionsService extends ShopifyApiRootCountableService< 22 | Interfaces.CustomCollection, // ShopifyObjectType 23 | CustomCollections, // ShopifyModelClass 24 | IShopifySyncCustomCollectionCountOptions, // CountOptions 25 | IShopifySyncCustomCollectionGetOptions, // GetOptions 26 | IShopifySyncCustomCollectionListOptions, // ListOptions 27 | CustomCollectionDocument // DatabaseDocumentType 28 | > { 29 | resourceName: Resource = "customCollections"; 30 | subResourceNames: Resource[] = []; 31 | 32 | constructor( 33 | @Inject("CustomCollectionModelToken") 34 | private readonly customCollectionModel: ( 35 | shopName: string 36 | ) => Model, 37 | @Inject("SyncProgressModelToken") 38 | private readonly eventService: EventService, 39 | @Inject("SyncProgressModelToken") 40 | private readonly syncProgressModel: Model, 41 | @Inject(SHOPIFY_MODULE_OPTIONS) 42 | protected readonly shopifyModuleOptions: ShopifyModuleOptions 43 | ) { 44 | super( 45 | customCollectionModel, 46 | CustomCollections, 47 | eventService, 48 | syncProgressModel, 49 | shopifyModuleOptions 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | // Services 2 | export { AccessScopesService } from "./access-scopes/access-scopes.service"; 3 | export { BlogsService } from "./blogs/blogs.service"; 4 | export { CollectsService } from "./collects/collects.service"; 5 | export { CustomCollectionsService } from "./custom-collections/custom-collections.service"; 6 | export { OrdersService } from "./orders/orders.service"; 7 | export { PagesService } from "./pages/pages.service"; 8 | export { ProductVariantsService } from "./products/product-variants/product-variants.service"; 9 | export { ProductsService } from "./products/products.service"; 10 | export { SearchService } from "./search/search.service"; 11 | export { SmartCollectionsService } from "./smart-collections/smart-collections.service"; 12 | export { ThemesService } from "./themes/themes.service"; 13 | 14 | // Interfaces 15 | export * from "./interfaces"; 16 | 17 | // Gateways 18 | export { WebhooksGateway } from "./webhooks/webhooks.gateway"; 19 | 20 | // Interceptor 21 | export { ApiCacheInterceptor } from "./api-cache.interceptor"; 22 | 23 | // Providers 24 | export { shopifyApiProviders } from "./api.providers"; 25 | -------------------------------------------------------------------------------- /src/api/interfaces/api-cache.ts: -------------------------------------------------------------------------------- 1 | export interface CachingConfig { 2 | ttl: number; 3 | } 4 | 5 | export interface StoreConfig extends CachingConfig { 6 | store: string; 7 | max?: number; 8 | isCacheableValue?: (value: any) => boolean; 9 | } 10 | 11 | export interface Cache { 12 | set( 13 | key: string, 14 | value: T, 15 | options: CachingConfig, 16 | callback?: (error: any) => void 17 | ): void; 18 | set( 19 | key: string, 20 | value: T, 21 | ttl: number, 22 | callback?: (error: any) => void 23 | ): void; 24 | set(key: string, value: T, options: CachingConfig): Promise; 25 | set(key: string, value: T, ttl: number): Promise; 26 | 27 | wrap( 28 | key: string, 29 | wrapper: (callback: (error: any, result: T) => void) => void, 30 | options: CachingConfig, 31 | callback: (error: any, result: T) => void 32 | ): void; 33 | wrap( 34 | key: string, 35 | wrapper: (callback: (error: any, result: T) => void) => void, 36 | callback: (error: any, result: T) => void 37 | ): void; 38 | wrap( 39 | key: string, 40 | wrapper: (callback: (error: any, result: T) => void) => any, 41 | options: CachingConfig 42 | ): Promise; 43 | wrap( 44 | key: string, 45 | wrapper: (callback: (error: any, result: T) => void) => void 46 | ): Promise; 47 | 48 | get(key: string, callback: (error: any, result: T) => void): void; 49 | get(key: string): Promise; 50 | 51 | del(key: string, callback: (error: any) => void): void; 52 | del(key: string): Promise; 53 | } 54 | -------------------------------------------------------------------------------- /src/api/interfaces/child-count.ts: -------------------------------------------------------------------------------- 1 | export interface ChildCount { 2 | count(parentId: number, options: CountOptions): Promise; 3 | } 4 | -------------------------------------------------------------------------------- /src/api/interfaces/child-get.ts: -------------------------------------------------------------------------------- 1 | import { ISyncOptions } from "./options"; 2 | export interface ChildGet< 3 | ShopifyObjectType, 4 | GetOptions extends ISyncOptions = ISyncOptions 5 | > { 6 | get( 7 | parentId: number, 8 | id: number, 9 | options?: GetOptions 10 | ): Promise; 11 | } 12 | -------------------------------------------------------------------------------- /src/api/interfaces/child-list.ts: -------------------------------------------------------------------------------- 1 | import { ISyncOptions } from "./options"; 2 | import { Options } from "shopify-admin-api"; 3 | export interface ChildList< 4 | ShopifyObjectType, 5 | ListOptions extends ISyncOptions & Options.BasicListOptions = ISyncOptions & 6 | Options.BasicListOptions 7 | > { 8 | list(parentId: number, options: ListOptions): Promise; 9 | } 10 | -------------------------------------------------------------------------------- /src/api/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./models"; 2 | export * from "./mongoose"; 3 | export * from "./options"; 4 | export * from "./api-cache"; 5 | export * from "./child-count"; 6 | export * from "./child-get"; 7 | export * from "./child-list"; 8 | export * from "./list-all-callback"; 9 | export * from "./root-count"; 10 | export * from "./root-get"; 11 | export * from "./root-list"; 12 | export * from "./shopify-base-object-type"; 13 | export * from "./options/sync"; 14 | -------------------------------------------------------------------------------- /src/api/interfaces/list-all-callback.ts: -------------------------------------------------------------------------------- 1 | export interface IListAllCallbackData { 2 | pages: number; 3 | page: number; 4 | data: T[]; 5 | } 6 | 7 | export type listAllCallback = ( 8 | error: Error, 9 | data: IListAllCallbackData 10 | ) => void; 11 | -------------------------------------------------------------------------------- /src/api/interfaces/models/assets.ts: -------------------------------------------------------------------------------- 1 | import { Interfaces } from "shopify-admin-api"; 2 | 3 | export interface IAppAsset extends Interfaces.Asset { 4 | json?: any; 5 | } 6 | -------------------------------------------------------------------------------- /src/api/interfaces/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./assets"; 2 | export * from "./locales"; 3 | -------------------------------------------------------------------------------- /src/api/interfaces/models/locales.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Interfaces for the custom locales api (Not supported by Shopify) 3 | */ 4 | 5 | import { IAppAsset } from "./assets"; 6 | 7 | export interface IAppLocales { 8 | [langcode: string]: any; 9 | } 10 | 11 | export interface IAppLocaleFile extends IAppAsset { 12 | json?: any; 13 | lang_code?: string; 14 | is_default?: boolean; 15 | locales?: IAppLocales; 16 | } 17 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/access-scope.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | 4 | export type AccessScopeDocument = DocumentDefinition & 5 | Document; 6 | 7 | export const AccessScopeSchema = new Schema({ 8 | /** 9 | * The handle (name) for the access scope. 10 | */ 11 | handle: String, 12 | }); 13 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/address.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | 4 | export type AddressDocument = DocumentDefinition & Document; 5 | 6 | export const AddressSchema = new Schema( 7 | { 8 | id: Number, 9 | address1: String, 10 | address2: String, 11 | city: String, 12 | company: String, 13 | country: String, 14 | country_code: String, 15 | country_name: String, 16 | customer_id: Number, 17 | default: Boolean, 18 | first_name: String, 19 | last_name: String, 20 | latitude: Number, 21 | longitude: Number, 22 | name: String, 23 | phone: String, 24 | province: String, 25 | province_code: String, 26 | zip: String, 27 | }, 28 | { 29 | minimize: false, 30 | } 31 | ); 32 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/article.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | 4 | export type ArticleDocument = DocumentDefinition & Document; 5 | 6 | export const ImageSchema = new Schema({ 7 | /** 8 | * A base64 image string only used when creating an image. It will be converted to the src property. 9 | */ 10 | attachment: String, 11 | 12 | /** 13 | * The date and time the image was created. 14 | */ 15 | created_at: String, 16 | 17 | /** 18 | * The image's src URL. 19 | */ 20 | src: String, 21 | }); 22 | 23 | export const ArticleSchema = new Schema({ 24 | id: { type: Number, index: { unique: true } }, 25 | /** 26 | * The name of the author of this article 27 | */ 28 | author: String, 29 | 30 | /** 31 | * A unique numeric identifier for the blog containing the article. 32 | */ 33 | blog_id: Number, 34 | 35 | /** 36 | * The text of the body of the article, complete with HTML markup. 37 | */ 38 | body_html: String, 39 | 40 | /** 41 | * The date and time when the article was created. 42 | */ 43 | created_at: String, 44 | 45 | /** 46 | * A human-friendly unique string for an article automatically generated from its title. It is used in the article's URL. 47 | */ 48 | handle: String, 49 | 50 | /** 51 | * The article image. 52 | */ 53 | image: ImageSchema, 54 | 55 | /** 56 | * States whether or not the article is visible. 57 | */ 58 | published: Boolean, 59 | 60 | /** 61 | * The date and time when the article was published. 62 | */ 63 | published_at: String, 64 | 65 | /** 66 | * The text of the summary of the article, complete with HTML markup. 67 | */ 68 | summary_html: String, 69 | 70 | /** 71 | * Tags are additional short descriptors formatted as a string of comma-separated values. 72 | * For example, if an article has three tags: tag1, tag2, tag3. 73 | */ 74 | tags: String, 75 | 76 | /** 77 | * States the name of the template an article is using if it is using an alternate template. 78 | * If an article is using the default article.liquid template, the value returned is null. 79 | */ 80 | template_suffix: String, 81 | 82 | /** 83 | * The title of the article. 84 | */ 85 | title: String, 86 | 87 | /** 88 | * The date and time when the article was last updated. 89 | */ 90 | updated_at: String, 91 | 92 | /** 93 | * A unique numeric identifier for the author of the article. 94 | */ 95 | user_id: Number, 96 | }); 97 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/asset.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | 4 | export type AssetDocument = DocumentDefinition & Document; 5 | 6 | export const AssetSchema = new Schema( 7 | { 8 | attachment: String, 9 | content_type: String, 10 | created_at: String, 11 | key: String, 12 | public_url: String, 13 | size: Number, 14 | theme_id: Number, 15 | updated_at: String, 16 | value: String, 17 | }, 18 | { 19 | minimize: false, 20 | } 21 | ); 22 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/blog.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | import { MetafieldSchema } from "./metafield.schema"; 4 | 5 | export type BlogDocument = DocumentDefinition & Document; 6 | 7 | export const BlogSchema = new Schema({ 8 | id: { type: Number, index: { unique: true } }, 9 | commentable: Boolean, 10 | created_at: String, 11 | feedburner: String, 12 | feedburner_location: String, 13 | handle: String, 14 | metafield: MetafieldSchema, 15 | tags: String, 16 | template_suffix: String, 17 | title: String, 18 | updated_at: String, 19 | }); 20 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/client-details.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | 4 | export type ClientDetailsDocument = 5 | DocumentDefinition & Document; 6 | 7 | export const ClientDetailsSchema = new Schema( 8 | { 9 | accept_language: String, 10 | browser_height: Number, 11 | browser_ip: String, 12 | browser_width: Number, 13 | session_hash: String, 14 | user_agent: String, 15 | }, 16 | { 17 | _id: false, 18 | minimize: false, 19 | } 20 | ); 21 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/collect.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | 4 | export type CollectDocument = DocumentDefinition & 5 | Document; 6 | 7 | export const CollectSchema = new Schema({ 8 | id: { type: Number, index: { unique: true } }, 9 | collection_id: Number, 10 | created_at: String, 11 | featured: Boolean, 12 | position: Number, 13 | product_id: Number, 14 | updated_at: String, 15 | sort_value: String, 16 | }); 17 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/collection-image.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from "mongoose"; 2 | 3 | export interface ICollectionImage { 4 | created_at: string; 5 | alt: string | null; 6 | width: number; 7 | height: number; 8 | src: string; 9 | } 10 | 11 | export const CollectionImageSchema = new Schema({ 12 | id: { type: Number, index: { unique: true, sparse: true } }, 13 | created_at: String, 14 | alt: String, 15 | width: Number, 16 | height: Number, 17 | src: String, 18 | }); 19 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/custom-collection.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | import { CollectionImageSchema } from "./collection-image.schema"; 4 | 5 | export type CustomCollectionDocument = 6 | DocumentDefinition & Document; 7 | 8 | export const CustomCollectionSchema = new Schema({ 9 | id: { type: Number, index: { unique: true } }, 10 | admin_graphql_api_id: String, 11 | body_html: String, 12 | handle: String, 13 | image: CollectionImageSchema, 14 | published_at: String, 15 | published_scope: String, 16 | sort_order: String, 17 | template_suffix: String, 18 | title: String, 19 | updated_at: String, 20 | metafield: String, 21 | }); 22 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/customer.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | import { AddressSchema } from "./address.schema"; 4 | 5 | export type CustomerDocument = DocumentDefinition & 6 | Document; 7 | 8 | export const CustomerSchema = new Schema( 9 | { 10 | id: Number, 11 | admin_graphql_api_id: String, 12 | accepts_marketing: Boolean, 13 | addresses: [AddressSchema], 14 | created_at: String, 15 | currency: String, 16 | default_address: AddressSchema, 17 | email: String, 18 | first_name: String, 19 | last_name: String, 20 | last_order_id: Number, 21 | last_order_name: String, 22 | multipass_identifier: String, 23 | note: String, 24 | orders_count: Number, 25 | phone: String, 26 | state: String, 27 | tags: String, 28 | tax_exempt: Boolean, 29 | total_spent: String, 30 | updated_at: String, 31 | verified_email: Boolean, 32 | }, 33 | { 34 | minimize: false, 35 | } 36 | ); 37 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/discount-allocation.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | import { PriceSetSchema } from "./price-set.schema"; 4 | 5 | export type DiscountAllocationDocument = 6 | DocumentDefinition & Document; 7 | 8 | export const DiscountAllocationSchema = new Schema( 9 | { 10 | amount: Number, 11 | amount_set: PriceSetSchema, 12 | discount_application_index: Number, 13 | }, 14 | { 15 | _id: false, 16 | minimize: false, 17 | } 18 | ); 19 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/discount-application.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | 4 | export type DiscountApplicationDocument = 5 | DocumentDefinition & Document; 6 | 7 | export const DiscountApplicationSchema = new Schema( 8 | { 9 | allocation_method: String, 10 | code: String, 11 | description: String, 12 | target_selection: String, 13 | target_type: String, 14 | title: String, 15 | type: String, 16 | value: String, 17 | value_type: String, 18 | }, 19 | { 20 | _id: false, 21 | minimize: false, 22 | } 23 | ); 24 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/discount-code.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | 4 | export type DiscountCodeDocument = DocumentDefinition & 5 | Document; 6 | 7 | export const DiscountCodeSchema = new Schema( 8 | { 9 | amount: String, 10 | code: String, 11 | type: String, 12 | }, 13 | { 14 | _id: false, 15 | minimize: false, 16 | } 17 | ); 18 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/fulfillment.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | import { LineItemSchema } from "./line-item.schema"; 4 | 5 | export type FulfillmentDocument = DocumentDefinition & 6 | Document; 7 | 8 | export const FulfillmentSchema = new Schema( 9 | { 10 | id: { type: Number, index: { unique: true, sparse: true } }, 11 | created_at: String, 12 | admin_graphql_api_id: String, 13 | line_items: [LineItemSchema], 14 | location_id: Number, 15 | name: String, 16 | notify_customer: Boolean, 17 | order_id: Number, 18 | receipt: Object, // arbitrary object without defined interface 19 | service: String, 20 | shipment_status: String, 21 | status: String, 22 | tracking_company: String, 23 | tracking_number: String, 24 | tracking_numbers: [String], 25 | tracking_url: String, 26 | tracking_urls: [String], 27 | updated_at: String, 28 | variant_inventory_management: String, 29 | }, 30 | { 31 | minimize: false, 32 | } 33 | ); 34 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./access-scope.schema"; 2 | export * from "./address.schema"; 3 | export * from "./article.schema"; 4 | export * from "./asset.schema"; 5 | export * from "./blog.schema"; 6 | export * from "./blog.schema"; 7 | export * from "./checkout.schema"; 8 | export * from "./collect.schema"; 9 | export * from "./customer.schema"; 10 | export * from "./custom-collection.schema"; 11 | export * from "./discount-allocation.schema"; 12 | export * from "./discount-application.schema"; 13 | export * from "./discount-code.schema"; 14 | export * from "./fulfillment.schema"; 15 | export * from "./line-item.schema"; 16 | export * from "./location.schema"; 17 | export * from "./note-attribute.schema"; 18 | export * from "./order.schema"; 19 | export * from "./page.schema"; 20 | export * from "./payment-details.schema"; 21 | export * from "./price-set.schema"; 22 | export * from "./product-variant.schema"; 23 | export * from "./product.schema"; 24 | export * from "./refund.schema"; 25 | export * from "./shipping-line.schema"; 26 | export * from "./smart-collection.schema"; 27 | export * from "./tax-line.schema"; 28 | export * from "./theme.schema"; 29 | export * from "./transaction.schema"; 30 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/line-item.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | import { TaxLineSchema } from "./tax-line.schema"; 4 | import { PriceSetSchema } from "./price-set.schema"; 5 | import { LocationSchema } from "./location.schema"; 6 | import { DiscountAllocationSchema } from "./discount-allocation.schema"; 7 | 8 | export type LineItemPropertyDocument = 9 | DocumentDefinition & Document; 10 | 11 | export type LineItemDocument = DocumentDefinition & 12 | Document; 13 | 14 | export const LineItemPropertySchema = new Schema({ 15 | name: String, 16 | value: String, // ? 17 | }); 18 | 19 | export const LineItemSchema = new Schema( 20 | { 21 | id: { type: Number, index: { unique: true, sparse: true } }, 22 | admin_graphql_api_id: String, 23 | discount_allocations: [DiscountAllocationSchema], 24 | destination_location: LocationSchema, 25 | fulfillable_quantity: Number, 26 | fulfillment_service: String, 27 | fulfillment_status: String, 28 | grams: Number, 29 | name: String, 30 | origin_location: LocationSchema, 31 | pre_tax_price: String, 32 | pre_tax_price_set: PriceSetSchema, 33 | price: String, 34 | price_set: PriceSetSchema, 35 | product_id: Number, 36 | product_exists: Boolean, 37 | quantity: Number, 38 | requires_shipping: Boolean, 39 | sku: String, 40 | title: String, 41 | variant_id: Number, 42 | variant_inventory_management: String, 43 | variant_title: String, 44 | vendor: String, 45 | gift_card: Boolean, 46 | taxable: Boolean, 47 | tax_lines: [TaxLineSchema], 48 | total_discount: String, 49 | total_discount_set: PriceSetSchema, 50 | properties: [LineItemPropertySchema], 51 | }, 52 | { 53 | minimize: false, 54 | } 55 | ); 56 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/location.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | 4 | export type LocationDocument = DocumentDefinition & 5 | Document; 6 | 7 | export const LocationSchema = new Schema( 8 | { 9 | id: { type: Number, index: { unique: true } }, 10 | country_code: String, 11 | province_code: String, 12 | name: String, 13 | address1: String, 14 | address2: String, 15 | city: String, 16 | zip: String, 17 | }, 18 | { 19 | minimize: false, 20 | } 21 | ); 22 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/metafield.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | 4 | export type MetafieldDocument = DocumentDefinition & 5 | Document; 6 | 7 | export const MetafieldSchema = new Schema({ 8 | id: { type: Number, index: { unique: true } }, 9 | updated_at: String, 10 | key: String, 11 | value: String, 12 | value_type: String, 13 | namespace: String, 14 | description: String, 15 | owner_id: Number, 16 | owner_resource: String, 17 | }); 18 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/note-attribute.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | 4 | export type NoteAttributeDocument = 5 | DocumentDefinition & Document; 6 | 7 | export const NoteAttributeSchema = new Schema( 8 | { 9 | name: String, 10 | value: String, // ? 11 | }, 12 | { 13 | minimize: false, 14 | } 15 | ); 16 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/order.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | 4 | import { AddressSchema } from "./address.schema"; 5 | import { ClientDetailsSchema } from "./client-details.schema"; 6 | import { CustomerSchema } from "./customer.schema"; 7 | import { DiscountCodeSchema } from "./discount-code.schema"; 8 | import { DiscountApplicationSchema } from "./discount-application.schema"; 9 | import { FulfillmentSchema } from "./fulfillment.schema"; 10 | import { RefundSchema } from "./refund.schema"; 11 | import { LineItemSchema } from "./line-item.schema"; 12 | import { NoteAttributeSchema } from "./note-attribute.schema"; 13 | import { PaymentDetailsSchema } from "./payment-details.schema"; 14 | import { PriceSetSchema } from "./price-set.schema"; 15 | import { ShippingLineSchema } from "./shipping-line.schema"; 16 | import { TaxLineSchema } from "./tax-line.schema"; 17 | 18 | export type OrderDocument = DocumentDefinition & Document; 19 | 20 | export const OrderSchema = new Schema( 21 | { 22 | id: { type: Number, index: { unique: true } }, 23 | admin_graphql_api_id: String, 24 | app_id: Number, 25 | billing_address: AddressSchema, 26 | browser_ip: String, 27 | buyer_accepts_marketing: Boolean, 28 | cancel_reason: String, 29 | cancelled_at: String, 30 | cart_token: String, 31 | checkout_id: Number, 32 | checkout_token: String, 33 | client_details: ClientDetailsSchema, 34 | closed_at: String, 35 | contact_email: String, 36 | confirmed: Boolean, 37 | created_at: String, 38 | currency: String, 39 | customer: CustomerSchema, 40 | customer_locale: String, 41 | device_id: Number, 42 | discount_applications: [DiscountApplicationSchema], 43 | discount_codes: [DiscountCodeSchema], 44 | email: String, 45 | financial_status: String, 46 | fulfillments: [FulfillmentSchema], 47 | fulfillment_status: String, 48 | gateway: String, 49 | refunds: [RefundSchema], 50 | tags: String, 51 | landing_site: String, 52 | landing_site_ref: String, 53 | line_items: [LineItemSchema], 54 | location_id: Number, 55 | name: String, 56 | note: String, 57 | note_attributes: [NoteAttributeSchema], 58 | number: Number, 59 | order_number: Number, 60 | order_status_url: String, 61 | phone: String, 62 | payment_details: PaymentDetailsSchema, 63 | payment_gateway_names: [String], 64 | presentment_currency: String, 65 | processed_at: String, 66 | processing_method: String, 67 | reference: String, 68 | referring_site: String, 69 | shipping_address: AddressSchema, 70 | shipping_lines: [ShippingLineSchema], 71 | source_identifier: String, 72 | source_name: String, 73 | source_url: String, 74 | subtotal_price: Number, 75 | subtotal_price_set: PriceSetSchema, 76 | tax_lines: [TaxLineSchema], 77 | taxes_included: Boolean, 78 | test: Boolean, 79 | token: String, 80 | total_discounts: Number, 81 | total_discounts_set: PriceSetSchema, 82 | total_line_items_price: Number, 83 | total_line_items_price_set: PriceSetSchema, 84 | total_shipping_price_set: PriceSetSchema, 85 | total_price: Number, 86 | total_price_usd: Number, 87 | total_price_set: PriceSetSchema, 88 | total_tax: Number, 89 | total_tax_set: PriceSetSchema, 90 | total_tip_received: String, 91 | total_weight: Number, 92 | updated_at: String, 93 | user_id: Number, 94 | }, 95 | { 96 | minimize: false, 97 | } 98 | ); 99 | /* 100 | OrderSchema.set('toJSON', { 101 | transform: function(doc, ret, options) { 102 | delete ret._id; 103 | delete ret.__v; 104 | } 105 | });*/ 106 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/page.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | import { MetafieldSchema } from "./metafield.schema"; 4 | 5 | export type PageDocument = DocumentDefinition & Document; 6 | 7 | export const PageSchema = new Schema({ 8 | id: { type: Number, index: { unique: true } }, 9 | author: String, 10 | body_html: String, 11 | created_at: String, 12 | handle: String, 13 | metafield: MetafieldSchema, 14 | published_at: String, 15 | shop_id: String, 16 | template_suffix: String, 17 | title: String, 18 | updated_at: String, 19 | }); 20 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/payment-details.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | 4 | export type PaymentDetailsDocument = 5 | DocumentDefinition & Document; 6 | 7 | export const PaymentDetailsSchema = new Schema( 8 | { 9 | avs_result_code: String, 10 | credit_card_bin: String, 11 | cvv_result_code: String, 12 | credit_card_number: String, 13 | credit_card_company: String, 14 | }, 15 | { 16 | minimize: false, 17 | } 18 | ); 19 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/price-set.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | 4 | export type PriceSetDocument = DocumentDefinition & 5 | Document; 6 | 7 | export const MoneySchema = new Schema({ 8 | amount: String, 9 | currency_code: String, 10 | }); 11 | 12 | export const PriceSetSchema = new Schema( 13 | { 14 | shop_money: MoneySchema, 15 | presentment_money: MoneySchema, 16 | }, 17 | { 18 | _id: false, 19 | minimize: false, 20 | } 21 | ); 22 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/product-variant.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | 4 | export type ProductVariantDocument = 5 | DocumentDefinition & Document; 6 | 7 | export const ProductVariantOptionSchema = new Schema({ 8 | id: { type: Number, index: { unique: true } }, 9 | product_id: Number, 10 | values: [String], 11 | }); 12 | 13 | export const ProductVariantSchema = new Schema({ 14 | id: { type: Number, index: { unique: true } }, 15 | admin_graphql_api_id: String, 16 | barcode: String, 17 | compare_at_price: String, 18 | created_at: String, 19 | fulfillment_service: String, 20 | grams: Number, 21 | image_id: Number, 22 | inventory_item_id: Number, 23 | inventory_management: String, 24 | inventory_policy: String, 25 | inventory_quantity: Number, 26 | old_inventory_quantity: Number, 27 | option1: String, 28 | option2: String, 29 | option3: String, 30 | position: Number, 31 | price: String, 32 | product_id: Number, 33 | requires_shipping: Boolean, 34 | sku: String, 35 | tax_code: String, 36 | taxable: Boolean, 37 | title: String, 38 | updated_at: String, 39 | weight: Number, 40 | weight_unit: String, 41 | }); 42 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/product.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | import { ProductVariantSchema } from "./product-variant.schema"; 4 | 5 | export type ProductDocument = DocumentDefinition & Document; 6 | 7 | export const ProductOptionSchema = new Schema({ 8 | id: { type: Number, index: { unique: true } }, 9 | product_id: Number, 10 | name: String, 11 | position: Number, 12 | values: [String], 13 | }); 14 | 15 | export const ProductImageSchema = new Schema({ 16 | id: { type: Number, index: { unique: true, sparse: true } }, 17 | admin_graphql_api_id: String, 18 | alt: String, 19 | created_at: String, 20 | height: Number, 21 | position: Number, 22 | product_id: Number, 23 | src: String, 24 | updated_at: String, 25 | variant_ids: [Number], 26 | width: Number, 27 | }); 28 | 29 | export const ProductSchema = new Schema({ 30 | id: { type: Number, index: { unique: true } }, 31 | admin_graphql_api_id: String, 32 | body_html: String, 33 | created_at: String, 34 | handle: String, 35 | image: ProductImageSchema, 36 | images: [ProductImageSchema], 37 | metafields_global_title_tag: String, 38 | metafields_global_description_tag: String, 39 | options: [ProductOptionSchema], 40 | product_type: String, 41 | published_at: String, 42 | published_scope: String, 43 | tags: String, 44 | template_suffix: String, 45 | title: String, 46 | updated_at: String, 47 | variants: [ProductVariantSchema], 48 | vendor: String, 49 | }); 50 | /* 51 | ProductSchema.set('toJSON', { 52 | transform: function(doc, ret, options) { 53 | delete ret._id; 54 | delete ret.__v; 55 | } 56 | });*/ 57 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/refund.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | import { TransactionSchema } from "./transaction.schema"; 4 | import { LineItemSchema } from "./line-item.schema"; 5 | import { PriceSetSchema } from "./price-set.schema"; 6 | 7 | export type OrderAdjustmentDocument = 8 | DocumentDefinition & Document; 9 | 10 | export type RefundLineItemDocument = 11 | DocumentDefinition & Document; 12 | 13 | export type RefundDocument = DocumentDefinition & Document; 14 | 15 | export const OrderAdjustmentSchema = new Schema( 16 | { 17 | id: { type: Number, index: { unique: true, sparse: true } }, 18 | amount: String, 19 | amount_set: PriceSetSchema, 20 | kind: String, 21 | order_id: Number, 22 | reason: String, 23 | refund_id: Number, 24 | tax_amount: String, 25 | tax_amount_set: PriceSetSchema, 26 | }, 27 | { 28 | minimize: false, 29 | } 30 | ); 31 | 32 | export const RefundLineItemSchema = new Schema( 33 | { 34 | id: { type: Number, index: { unique: true, sparse: true } }, 35 | line_item_id: Number, 36 | line_item: LineItemSchema, 37 | location_id: Number, 38 | quantity: Number, 39 | restock_type: String, 40 | subtotal: Number, 41 | subtotal_set: PriceSetSchema, 42 | total_tax: Number, // Most money values are stored as strings, but this is actually stored as a number 43 | total_tax_set: PriceSetSchema, 44 | }, 45 | { 46 | minimize: false, 47 | } 48 | ); 49 | 50 | export const RefundSchema = new Schema( 51 | { 52 | admin_graphql_api_id: String, 53 | id: { type: Number, index: { unique: true, sparse: true } }, 54 | created_at: String, 55 | note: String, 56 | order_adjustments: [OrderAdjustmentSchema], 57 | order_id: Number, 58 | processed_at: String, 59 | refund_line_items: [RefundLineItemSchema], 60 | restock: Boolean, 61 | user_id: Number, 62 | transactions: [TransactionSchema], 63 | }, 64 | { 65 | minimize: false, 66 | } 67 | ); 68 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/shipping-line.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | import { TaxLineSchema } from "./tax-line.schema"; 4 | import { PriceSetSchema } from "./price-set.schema"; 5 | import { DiscountAllocationSchema } from "./discount-allocation.schema"; 6 | 7 | export type ShippingLineDocument = DocumentDefinition & 8 | Document; 9 | 10 | export const ShippingLineSchema = new Schema( 11 | { 12 | id: { type: Number, index: { unique: true } }, 13 | carrier_identifier: String, 14 | code: String, 15 | delivery_category: Object, // undocumented, always null in all known test data 16 | discount_allocations: [DiscountAllocationSchema], 17 | discounted_price: String, 18 | discounted_price_set: PriceSetSchema, 19 | phone: String, 20 | price: String, 21 | price_set: PriceSetSchema, 22 | requested_fulfillment_service_id: Number, 23 | source: String, 24 | title: String, 25 | tax_lines: [TaxLineSchema], 26 | }, 27 | { 28 | minimize: false, 29 | } 30 | ); 31 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/smart-collection.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | import { CollectionImageSchema } from "./collection-image.schema"; 4 | 5 | export type SmartCollectionDocument = 6 | DocumentDefinition & Document; 7 | 8 | export const SmartCollectionRuleSchema = new Schema({ 9 | column: String, 10 | relation: String, 11 | condition: String, 12 | }); 13 | 14 | export const SmartCollectionSchema = new Schema({ 15 | id: { type: Number, index: { unique: true } }, 16 | admin_graphql_api_id: String, 17 | body_html: String, 18 | handle: String, 19 | image: CollectionImageSchema, 20 | published_at: String, 21 | published_scope: String, 22 | sort_order: String, 23 | template_suffix: String, 24 | title: String, 25 | updated_at: String, 26 | // only for smart collection 27 | disjunctive: Boolean, 28 | rules: [SmartCollectionRuleSchema], 29 | }); 30 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/tax-line.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | import { PriceSetSchema } from "./price-set.schema"; 4 | 5 | export type TaxLineDocument = DocumentDefinition & Document; 6 | 7 | export const TaxLineSchema = new Schema( 8 | { 9 | price: Number, 10 | rate: Number, 11 | title: String, 12 | price_set: PriceSetSchema, 13 | }, 14 | { 15 | _id: false, 16 | minimize: false, 17 | } 18 | ); 19 | /* 20 | TaxLineSchema.set('toJSON', { 21 | transform: function(doc, ret, options) { 22 | delete ret._id; 23 | delete ret.__parentArray; 24 | delete ret.__index; 25 | } 26 | });*/ 27 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/theme.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | 4 | export type ThemeDocument = DocumentDefinition & Document; 5 | 6 | export const ThemeSchema = new Schema( 7 | { 8 | name: String, 9 | created_at: String, 10 | updated_at: String, 11 | role: String, 12 | theme_store_id: Number, 13 | previewable: Boolean, 14 | processing: Boolean, 15 | }, 16 | { 17 | minimize: false, 18 | } 19 | ); 20 | -------------------------------------------------------------------------------- /src/api/interfaces/mongoose/transaction.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document, DocumentDefinition } from "mongoose"; 2 | import { Interfaces } from "shopify-admin-api"; 3 | import { PaymentDetailsSchema } from "./payment-details.schema"; 4 | 5 | export type TransactionDocument = DocumentDefinition & 6 | Document; 7 | 8 | export const TransactionSchema = new Schema( 9 | { 10 | id: { type: Number, index: { unique: true } }, 11 | admin_graphql_api_id: String, 12 | amount: String, 13 | authorization: String, 14 | created_at: String, 15 | currency: String, 16 | device_id: Number, 17 | error_code: String, 18 | gateway: String, 19 | kind: String, 20 | location_id: Number, 21 | message: String, 22 | order_id: Number, 23 | parent_id: Number, 24 | payment_details: PaymentDetailsSchema, 25 | processed_at: String, 26 | receipt: Object, // arbitrary object without defined interface 27 | source_name: String, 28 | status: String, 29 | test: Boolean, 30 | user_id: Number, 31 | }, 32 | { 33 | minimize: false, 34 | } 35 | ); 36 | 37 | /* 38 | TransactionSchema.set('toJSON', { 39 | transform: function(doc, ret, options) { 40 | delete ret._id; 41 | delete ret.__v; 42 | } 43 | });*/ 44 | -------------------------------------------------------------------------------- /src/api/interfaces/options/article.ts: -------------------------------------------------------------------------------- 1 | import { Options } from "shopify-admin-api"; 2 | import { ISyncOptions } from "./sync"; 3 | import { IAppListSortOptions, IAppListFilterOptions } from "./basic"; 4 | 5 | export interface IShopifySyncArticleListOptions 6 | extends Options.ArticleListOptions, 7 | ISyncOptions {} 8 | export interface IShopifySyncArticleGetOptions 9 | extends Options.ArticleGetOptions, 10 | ISyncOptions {} 11 | export type IShopifySyncArticleCountOptions = Options.ArticleCountOptions; 12 | 13 | export interface IAppArticleListOptions 14 | extends Options.ArticleListOptions, 15 | IAppListSortOptions, 16 | IAppListFilterOptions {} 17 | export type IAppArticleGetOptions = Options.ArticleGetOptions; 18 | export type IAppArticleCountOptions = Options.ArticleCountOptions; 19 | -------------------------------------------------------------------------------- /src/api/interfaces/options/asset.ts: -------------------------------------------------------------------------------- 1 | import { Options } from "shopify-admin-api"; 2 | 3 | export interface IAppAssetListOptions extends Options.FieldOptions { 4 | key_starts_with?: string; 5 | content_type?: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/api/interfaces/options/basic.ts: -------------------------------------------------------------------------------- 1 | import { Options } from "shopify-admin-api"; 2 | 3 | /** 4 | * Sort options for listFromDb and listFromES methods 5 | */ 6 | export interface IAppListSortOptions { 7 | /** 8 | * Property to sort by 9 | */ 10 | sort_by?: string; 11 | /** 12 | * Sort direction 13 | */ 14 | sort_dir?: "asc" | "desc"; 15 | } 16 | 17 | export interface IAppListFilterOptions { 18 | /** 19 | * Return only certain documents, specified by a comma-separated list of document IDs. 20 | */ 21 | ids?: string; 22 | 23 | /** 24 | * Full text search for a string e.g. in `title` or `body_html` 25 | */ 26 | text?: string; 27 | } 28 | 29 | /** 30 | * Basic list options wich should be implementated by listFromDb and listFromES 31 | */ 32 | export interface IAppBasicListOptions 33 | extends IAppListSortOptions, 34 | IAppListFilterOptions, 35 | Options.FieldOptions, 36 | Options.BasicListOptions, 37 | Options.DateOptions, 38 | Options.PublishedOptions {} 39 | -------------------------------------------------------------------------------- /src/api/interfaces/options/blog.ts: -------------------------------------------------------------------------------- 1 | import { Options } from "shopify-admin-api"; 2 | import { ISyncOptions } from "./sync"; 3 | import { IAppListSortOptions, IAppListFilterOptions } from "./basic"; 4 | 5 | export interface IShopifySyncBlogListOptions 6 | extends Options.BlogListOptions, 7 | ISyncOptions {} 8 | export interface IShopifySyncBlogGetOptions 9 | extends Options.BlogGetOptions, 10 | ISyncOptions {} 11 | export type IShopifySyncBlogCountOptions = Options.BlogCountOptions; 12 | 13 | export interface IAppBlogListOptions 14 | extends Options.BlogListOptions, 15 | IAppListSortOptions, 16 | IAppListFilterOptions {} 17 | export type IAppBlogGetOptions = Options.BlogGetOptions; 18 | export type IAppBlogCountOptions = Options.BlogCountOptions; 19 | -------------------------------------------------------------------------------- /src/api/interfaces/options/checkout.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-interface */ 2 | import { Options } from "shopify-admin-api"; 3 | import { ISyncOptions } from "./sync"; 4 | import { IAppListSortOptions, IAppListFilterOptions } from "./basic"; 5 | 6 | /** 7 | * Product variant options to get a list of products from shopify 8 | */ 9 | export interface IShopifySyncCheckoutListOptions 10 | extends Options.CheckoutListOptions, 11 | ISyncOptions {} 12 | export interface IShopifySyncCheckoutGetOptions 13 | extends Options.CheckoutGetOptions, 14 | ISyncOptions {} 15 | export interface IShopifySyncCheckoutCountOptions {} 16 | 17 | /** 18 | * Product variant options to get a list of products from the app 19 | */ 20 | export interface IAppCheckoutListOptions 21 | extends IAppListSortOptions, 22 | IAppListFilterOptions {} 23 | export interface IAppCheckoutGetOptions {} 24 | export interface IAppCheckoutCountOptions {} 25 | -------------------------------------------------------------------------------- /src/api/interfaces/options/collect.ts: -------------------------------------------------------------------------------- 1 | import { Options } from "shopify-admin-api"; 2 | import { ISyncOptions } from "./sync"; 3 | import { IAppListSortOptions, IAppListFilterOptions } from "./basic"; 4 | 5 | /** 6 | * Order options to get a list of collects from shopify 7 | */ 8 | export interface IShopifySyncCollectListOptions 9 | extends Options.CollectListOptions, 10 | ISyncOptions {} 11 | export interface IShopifySyncCollectGetOptions 12 | extends Options.CollectGetOptions, 13 | ISyncOptions {} 14 | export type IShopifySyncCollectCountOptions = Options.CollectCountOptions; 15 | 16 | /** 17 | * Order options to get a list of collects from the app 18 | */ 19 | export interface IAppCollectListOptions 20 | extends Options.CollectListOptions, 21 | IAppListSortOptions, 22 | IAppListFilterOptions {} 23 | export type IAppCollectGetOptions = Options.CollectGetOptions; 24 | export type IAppCollectCountOptions = Options.CollectCountOptions; 25 | -------------------------------------------------------------------------------- /src/api/interfaces/options/custom-collection.ts: -------------------------------------------------------------------------------- 1 | import { Options } from "shopify-admin-api"; 2 | import { ISyncOptions } from "./sync"; 3 | import { IAppListSortOptions, IAppListFilterOptions } from "./basic"; 4 | 5 | export interface IShopifySyncCustomCollectionListOptions 6 | extends Options.CollectionListOptions, 7 | ISyncOptions {} 8 | export interface IShopifySyncCustomCollectionGetOptions 9 | extends Options.CollectionGetOptions, 10 | ISyncOptions {} 11 | export interface IShopifySyncCustomCollectionCountOptions 12 | extends Options.CollectionListOptions, 13 | ISyncOptions {} 14 | 15 | export interface IAppCustomCollectionListOptions 16 | extends Options.CollectionListOptions, 17 | IAppListSortOptions, 18 | IAppListFilterOptions {} 19 | export type IAppCustomCollectionGetOptions = Options.CollectionGetOptions; 20 | export type IAppCustomCollectionCountOptions = Options.CollectionListOptions; 21 | -------------------------------------------------------------------------------- /src/api/interfaces/options/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./article"; 2 | export * from "./asset"; 3 | export * from "./basic"; 4 | export * from "./blog"; 5 | export * from "./checkout"; 6 | export * from "./collect"; 7 | export * from "./custom-collection"; 8 | export * from "./locale"; 9 | export * from "./order"; 10 | export * from "./page"; 11 | export * from "./product-variants"; 12 | export * from "./product"; 13 | export * from "./smart-collection"; 14 | export * from "./sync"; 15 | export * from "./theme"; 16 | export * from "./transactions"; 17 | -------------------------------------------------------------------------------- /src/api/interfaces/options/locale.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Interfaces for the custom locales api (Not supported by Shopify) 3 | */ 4 | 5 | import { IAppAssetListOptions } from "./asset"; 6 | 7 | export interface IAppLocaleListOptions extends IAppAssetListOptions { 8 | lang_code?: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/api/interfaces/options/order.ts: -------------------------------------------------------------------------------- 1 | import { Options } from "shopify-admin-api"; 2 | import { ISyncOptions } from "./sync"; 3 | import { IAppListSortOptions, IAppListFilterOptions } from "./basic"; 4 | 5 | /** 6 | * Order options to get a list of orders from shopify 7 | */ 8 | export interface IShopifySyncOrderListOptions 9 | extends Options.OrderListOptions, 10 | ISyncOptions {} 11 | export interface IShopifySyncOrderGetOptions 12 | extends Options.OrderGetOptions, 13 | ISyncOptions {} 14 | export type IShopifySyncOrderCountOptions = Options.OrderCountOptions; 15 | 16 | /** 17 | * Order options to get a list of orders from the app 18 | */ 19 | export interface IAppOrderListOptions 20 | extends Options.OrderListOptions, 21 | IAppListSortOptions, 22 | IAppListFilterOptions {} 23 | export type IAppOrderGetOptions = Options.OrderGetOptions; 24 | export type IAppOrderCountOptions = Options.OrderCountOptions; 25 | -------------------------------------------------------------------------------- /src/api/interfaces/options/page.ts: -------------------------------------------------------------------------------- 1 | import { Options } from "shopify-admin-api"; 2 | import { ISyncOptions } from "./sync"; 3 | import { IAppListSortOptions, IAppListFilterOptions } from "./basic"; 4 | 5 | export interface IShopifySyncPageListOptions 6 | extends Options.PageListOptions, 7 | ISyncOptions {} 8 | export interface IShopifySyncPageGetOptions 9 | extends Options.PageGetOptions, 10 | ISyncOptions {} 11 | export type IShopifySyncPageCountOptions = Options.PageCountOptions; 12 | 13 | export interface IAppPageListOptions 14 | extends Options.PageListOptions, 15 | IAppListSortOptions, 16 | IAppListFilterOptions {} 17 | export type IAppPageGetOptions = Options.PageGetOptions; 18 | export type IAppPageCountOptions = Options.PageCountOptions; 19 | -------------------------------------------------------------------------------- /src/api/interfaces/options/product-variants.ts: -------------------------------------------------------------------------------- 1 | import { Options } from "shopify-admin-api"; 2 | import { ISyncOptions } from "./sync"; 3 | import { IAppListSortOptions, IAppListFilterOptions } from "./basic"; 4 | 5 | /** 6 | * Product variant options to get a list of products from shopify 7 | */ 8 | export interface IShopifySyncProductVariantListOptions 9 | extends Options.ProductVariantListOptions, 10 | ISyncOptions {} 11 | export interface IShopifySyncProductVariantGetOptions 12 | extends Options.ProductVariantGetOptions, 13 | ISyncOptions {} 14 | export type IShopifySyncProductVariantCountOptions = 15 | Options.ProductVariantCountOptions; 16 | 17 | /** 18 | * Product variant options to get a list of products from the app 19 | */ 20 | export interface IAppProductVariantListOptions 21 | extends Options.ProductVariantListOptions, 22 | IAppListSortOptions, 23 | IAppListFilterOptions {} 24 | export type IAppProductVariantGetOptions = Options.ProductVariantGetOptions; 25 | export type IAppProductVariantCountOptions = Options.ProductVariantCountOptions; 26 | -------------------------------------------------------------------------------- /src/api/interfaces/options/product.ts: -------------------------------------------------------------------------------- 1 | import { Options } from "shopify-admin-api"; 2 | import { ISyncOptions } from "./sync"; 3 | import { IAppListSortOptions, IAppListFilterOptions } from "./basic"; 4 | 5 | /** 6 | * Product options to get a list of products from shopify 7 | */ 8 | export interface IShopifySyncProductListOptions 9 | extends Options.ProductListOptions, 10 | ISyncOptions {} 11 | export interface IShopifySyncProductGetOptions 12 | extends Options.ProductGetOptions, 13 | ISyncOptions {} 14 | export type IShopifySyncProductCountOptions = Options.ProductCountOptions; 15 | 16 | /** 17 | * Product options to get a list of products from the app 18 | */ 19 | export interface IAppProductListOptions 20 | extends Options.ProductListOptions, 21 | IAppListSortOptions, 22 | IAppListFilterOptions { 23 | price_max?: number; 24 | price_min?: number; 25 | } 26 | export type IAppProductGetOptions = Options.ProductGetOptions; 27 | export type IAppProductCountOptions = Options.ProductCountOptions; 28 | -------------------------------------------------------------------------------- /src/api/interfaces/options/smart-collection.ts: -------------------------------------------------------------------------------- 1 | import { Options } from "shopify-admin-api"; 2 | import { ISyncOptions } from "./sync"; 3 | import { IAppListSortOptions, IAppListFilterOptions } from "./basic"; 4 | 5 | export interface IShopifySyncSmartCollectionListOptions 6 | extends Options.CollectionListOptions, 7 | ISyncOptions {} 8 | export interface IShopifySyncSmartCollectionGetOptions 9 | extends Options.CollectionGetOptions, 10 | ISyncOptions {} 11 | export interface IShopifySyncSmartCollectionCountOptions 12 | extends Options.CollectionListOptions, 13 | ISyncOptions {} 14 | 15 | export interface IAppSmartCollectionListOptions 16 | extends Options.CollectionListOptions, 17 | IAppListSortOptions, 18 | IAppListFilterOptions {} 19 | export type IAppSmartCollectionGetOptions = Options.CollectionGetOptions; 20 | export type IAppSmartCollectionCountOptions = Options.CollectionCountOptions; 21 | -------------------------------------------------------------------------------- /src/api/interfaces/options/sync.ts: -------------------------------------------------------------------------------- 1 | export interface ISyncOptions { 2 | /** 3 | * If true, sync the receive data to the internal database (MongoDB) 4 | */ 5 | syncToDb?: boolean; 6 | failOnSyncError?: boolean; 7 | cancelSignal?: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/api/interfaces/options/theme.ts: -------------------------------------------------------------------------------- 1 | import { Options } from "shopify-admin-api"; 2 | import { ISyncOptions } from "./sync"; 3 | 4 | /** 5 | * Custom filter implementation (Not supported by Shopify) 6 | */ 7 | export interface IAppThemeListFilter { 8 | name?: string; 9 | created_at?: string; 10 | updated_at?: string; 11 | role?: "main" | "unpublished" | "demo"; 12 | previewable?: boolean; 13 | processing?: boolean; 14 | } 15 | 16 | export interface IShopifySyncThemeListOptions 17 | extends Options.ThemeListOptions, 18 | ISyncOptions {} 19 | export interface IShopifySyncThemeGetOptions 20 | extends Options.ThemeGetOptions, 21 | ISyncOptions {} 22 | 23 | export type IAppThemeListOptions = Options.ThemeListOptions; 24 | export type IAppThemeGetOptions = Options.ThemeGetOptions; 25 | -------------------------------------------------------------------------------- /src/api/interfaces/options/transactions.ts: -------------------------------------------------------------------------------- 1 | import { Options } from "shopify-admin-api"; 2 | import { ISyncOptions } from "./sync"; 3 | import { IAppListSortOptions, IAppListFilterOptions } from "./basic"; 4 | 5 | /** 6 | * Transaction options to get a list of transactions from shopify 7 | */ 8 | export interface IShopifySyncTransactionListOptions 9 | extends Options.TransactionListOptions, 10 | ISyncOptions {} 11 | export interface IShopifySyncTransactionGetOptions 12 | extends Options.TransactionGetOptions, 13 | ISyncOptions {} 14 | export type IShopifySyncTransactionCountOptions = 15 | Options.TransactionCountOptions; 16 | 17 | /** 18 | * Transaction options to get a list of Transactions from the app 19 | */ 20 | export interface IAppTransactionListOptions 21 | extends Options.TransactionListOptions, 22 | IAppListSortOptions, 23 | IAppListFilterOptions {} 24 | export type IAppTransactionGetOptions = Options.TransactionGetOptions; 25 | export type IAppTransactionCountOptions = Options.TransactionCountOptions; 26 | -------------------------------------------------------------------------------- /src/api/interfaces/root-count.ts: -------------------------------------------------------------------------------- 1 | export interface RootCount { 2 | count(options: CountOptions): Promise; 3 | } 4 | -------------------------------------------------------------------------------- /src/api/interfaces/root-get.ts: -------------------------------------------------------------------------------- 1 | import { ISyncOptions } from "./options"; 2 | export interface RootGet< 3 | ShopifyObjectType, 4 | GetOptions extends ISyncOptions = ISyncOptions 5 | > { 6 | get( 7 | id: number, 8 | options?: GetOptions 9 | ): Promise | null>; 10 | } 11 | -------------------------------------------------------------------------------- /src/api/interfaces/root-list.ts: -------------------------------------------------------------------------------- 1 | import { ISyncOptions } from "./options"; 2 | import { Options } from "shopify-admin-api"; 3 | export interface RootList< 4 | ShopifyObjectType, 5 | ListOptions extends ISyncOptions & Options.BasicListOptions = ISyncOptions & 6 | Options.BasicListOptions 7 | > { 8 | list(options: ListOptions): Promise[]>; 9 | } 10 | -------------------------------------------------------------------------------- /src/api/interfaces/shopify-base-object-type.ts: -------------------------------------------------------------------------------- 1 | export interface ShopifyBaseObjectType { 2 | id: number; 3 | } 4 | -------------------------------------------------------------------------------- /src/api/orders/orders.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { OrdersController } from './orders.controller'; 3 | 4 | import { ShopifyModule } from '../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('Orders Controller', () => { 9 | let module: TestingModule; 10 | 11 | beforeAll(async () => { 12 | module = await Test.createTestingModule({ 13 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 14 | }).compile(); 15 | }); 16 | it('should be defined', () => { 17 | const controller: OrdersController = module.get(OrdersController); 18 | expect(controller).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/api/orders/orders.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { OrdersService } from './orders.service'; 3 | 4 | import { ShopifyModule } from '../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('OrdersService', () => { 9 | let service: OrdersService; 10 | 11 | beforeAll(async () => { 12 | const module = await Test.createTestingModule({ 13 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 14 | }).compile(); 15 | service = module.get(OrdersService); 16 | }); 17 | it('should be defined', () => { 18 | expect(service).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/api/orders/orders.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from "@nestjs/common"; 2 | import { EventService } from "../../event.service"; 3 | import { TransactionsService } from "./transactions/transactions.service"; 4 | import { ShopifyApiRootCountableService } from "../shopify-api-root-countable.service"; 5 | 6 | // Interfaces 7 | import { Model } from "mongoose"; 8 | import { Interfaces } from "shopify-admin-api"; 9 | import { Orders } from "shopify-admin-api"; 10 | import { 11 | OrderDocument, 12 | IShopifySyncOrderCountOptions, 13 | IShopifySyncOrderGetOptions, 14 | IShopifySyncOrderListOptions, 15 | } from "../interfaces"; 16 | import { 17 | SyncProgressDocument, 18 | IStartSyncOptions, 19 | OrderSyncProgressDocument, 20 | Resource, 21 | ShopifyModuleOptions, 22 | } from "../../interfaces"; 23 | import { IListAllCallbackData } from "../../api/interfaces"; 24 | import { IShopifyConnect } from "../../auth/interfaces/connect"; 25 | import { mongooseParallelRetry } from "../../helpers"; 26 | import { SHOPIFY_MODULE_OPTIONS } from "../../shopify.constants"; 27 | 28 | @Injectable() 29 | export class OrdersService extends ShopifyApiRootCountableService< 30 | Interfaces.Order, // ShopifyObjectType 31 | Orders, // ShopifyModelClass 32 | IShopifySyncOrderCountOptions, // CountOptions 33 | IShopifySyncOrderGetOptions, // GetOptions 34 | IShopifySyncOrderListOptions, // ListOptions 35 | OrderDocument // DatabaseDocumentType 36 | > { 37 | resourceName: Resource = "orders"; 38 | subResourceNames: Resource[] = ["transactions"]; 39 | 40 | constructor( 41 | @Inject("OrderModelToken") 42 | private readonly orderModel: (shopName: string) => Model, 43 | @Inject("SyncProgressModelToken") 44 | private readonly syncProgressModel: Model, 45 | protected readonly eventService: EventService, 46 | private readonly transactionsService: TransactionsService, 47 | @Inject(SHOPIFY_MODULE_OPTIONS) 48 | protected readonly shopifyModuleOptions: ShopifyModuleOptions 49 | ) { 50 | super( 51 | orderModel, 52 | Orders, 53 | eventService, 54 | syncProgressModel, 55 | shopifyModuleOptions 56 | ); 57 | } 58 | 59 | /** 60 | * Sub-routine to configure the sync. 61 | * In case of orders we have to check if transactions should be included. 62 | * 63 | * @param shopifyConnect 64 | * @param subProgress 65 | * @param options 66 | * @param data 67 | */ 68 | protected async syncedDataCallback( 69 | shopifyConnect: IShopifyConnect, 70 | progress: SyncProgressDocument, 71 | subProgress: OrderSyncProgressDocument, 72 | options: IStartSyncOptions, 73 | data: IListAllCallbackData 74 | ): Promise { 75 | const orders = data.data; 76 | const lastOrder = orders[orders.length - 1]; 77 | if (options.includeTransactions) { 78 | for (const order of orders) { 79 | const transactions = await this.transactionsService.listFromShopify( 80 | shopifyConnect, 81 | order.id, 82 | { 83 | syncToDb: options.syncToDb, 84 | } 85 | ); 86 | subProgress.syncedTransactionsCount += transactions.length; 87 | subProgress.syncedCount++; 88 | subProgress.lastId = order.id; 89 | subProgress.info = order.name; 90 | await mongooseParallelRetry(() => { 91 | return progress.save(); 92 | }); 93 | } 94 | } else { 95 | subProgress.syncedCount += orders.length; 96 | subProgress.lastId = lastOrder.id; 97 | subProgress.info = lastOrder.name; 98 | } 99 | } 100 | 101 | /** 102 | * 103 | * @param syncOptions 104 | */ 105 | protected getSyncCountOptions( 106 | syncOptions: IStartSyncOptions 107 | ): IShopifySyncOrderCountOptions { 108 | this.logger.debug(`getSyncCountOptions: %O`, syncOptions); 109 | this.logger.debug("status %o:", { status: "any" }); 110 | return { status: "any" }; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/api/orders/transactions/transactions.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { TransactionsController } from './transactions.controller'; 3 | 4 | import { ShopifyModule } from '../../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('Transactions Controller', () => { 9 | let module: TestingModule; 10 | 11 | beforeAll(async () => { 12 | module = await Test.createTestingModule({ 13 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 14 | }).compile(); 15 | }); 16 | it('should be defined', () => { 17 | const controller: TransactionsController = module.get(TransactionsController); 18 | expect(controller).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/api/orders/transactions/transactions.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Param, 4 | Query, 5 | UseGuards, 6 | Req, 7 | Get, 8 | HttpStatus, 9 | HttpException, 10 | } from "@nestjs/common"; 11 | 12 | import { IUserRequest } from "../../../interfaces/user-request"; 13 | import { TransactionsService } from "./transactions.service"; 14 | import { DebugService } from "../../../debug.service"; 15 | 16 | import { ShopifyApiGuard } from "../../../guards/shopify-api.guard"; 17 | import { Roles } from "../../../guards/roles.decorator"; 18 | 19 | import { 20 | IShopifySyncTransactionGetOptions, 21 | IShopifySyncTransactionListOptions, 22 | } from "../../interfaces"; 23 | 24 | @Controller("shopify/api/orders") 25 | export class TransactionsController { 26 | constructor(protected readonly transactionsService: TransactionsService) {} 27 | logger = new DebugService(`shopify:${this.constructor.name}`); 28 | 29 | @UseGuards(ShopifyApiGuard) 30 | @Roles("shopify-staff-member") 31 | @Get(":order_id/transactions") 32 | async listFromShopify( 33 | @Req() req: IUserRequest, 34 | @Param("order_id") orderId: number, 35 | @Query() options: IShopifySyncTransactionListOptions 36 | ) { 37 | return this.transactionsService 38 | .listFromShopify(req.session[`shopify-connect-${req.shop}`], orderId, { 39 | ...options, 40 | }) 41 | .catch((error: Error) => { 42 | this.logger.error(error); 43 | throw new HttpException( 44 | error.message, 45 | HttpStatus.INTERNAL_SERVER_ERROR 46 | ); 47 | }); 48 | } 49 | 50 | @UseGuards(ShopifyApiGuard) 51 | @Roles("shopify-staff-member") 52 | @Get(":order_id/transactions/db") 53 | async listFromDb( 54 | @Req() req: IUserRequest, 55 | @Param("order_id") orderId: number 56 | ) { 57 | try { 58 | return await this.transactionsService.listFromDb( 59 | req.session[`shopify-connect-${req.shop}`], 60 | orderId 61 | ); 62 | } catch (error) { 63 | this.logger.error(error); 64 | throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); 65 | } 66 | } 67 | 68 | @UseGuards(ShopifyApiGuard) 69 | @Roles("shopify-staff-member") 70 | @Get(":order_id/transactions/count") 71 | async countFromShopify( 72 | @Req() req: IUserRequest, 73 | @Param("order_id") orderId: number 74 | ) { 75 | return this.transactionsService 76 | .countFromShopify(req.session[`shopify-connect-${req.shop}`], orderId) 77 | .catch((error: Error) => { 78 | this.logger.error(error); 79 | throw new HttpException( 80 | error.message, 81 | HttpStatus.INTERNAL_SERVER_ERROR 82 | ); 83 | }); 84 | } 85 | 86 | @UseGuards(ShopifyApiGuard) 87 | @Roles("shopify-staff-member") 88 | @Get(":order_id/transactions/db/count") 89 | async countFromDb( 90 | @Req() req: IUserRequest, 91 | @Param("order_id") orderId: number 92 | ) { 93 | return this.transactionsService 94 | .countFromDb(req.session[`shopify-connect-${req.shop}`], orderId) 95 | .catch((error: Error) => { 96 | this.logger.error(error); 97 | throw new HttpException( 98 | error.message, 99 | HttpStatus.INTERNAL_SERVER_ERROR 100 | ); 101 | }); 102 | } 103 | 104 | @UseGuards(ShopifyApiGuard) 105 | @Roles("shopify-staff-member") 106 | @Get(":order_id/transactions/:id/db") 107 | async getFromDb(@Req() req: IUserRequest, @Param("id") id: number) { 108 | try { 109 | return await this.transactionsService.getFromDb( 110 | req.session[`shopify-connect-${req.shop}`], 111 | id 112 | ); 113 | } catch (error) { 114 | this.logger.error(error); 115 | throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); 116 | } 117 | } 118 | 119 | @UseGuards(ShopifyApiGuard) 120 | @Roles("shopify-staff-member") 121 | @Get(":order_id/transactions/:id") 122 | getFromShopify( 123 | @Req() req: IUserRequest, 124 | @Param("order_id") orderId, 125 | @Param("id") id: number, 126 | @Query() options: IShopifySyncTransactionGetOptions 127 | ) { 128 | return this.transactionsService 129 | .getFromShopify( 130 | req.session[`shopify-connect-${req.shop}`], 131 | orderId, 132 | id, 133 | options 134 | ) 135 | .catch((error: Error) => { 136 | this.logger.error(error); 137 | throw new HttpException( 138 | error.message, 139 | HttpStatus.INTERNAL_SERVER_ERROR 140 | ); 141 | }); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/api/orders/transactions/transactions.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { TransactionsService } from './transactions.service'; 3 | 4 | import { ShopifyModule } from '../../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('TransactionsService', () => { 9 | let service: TransactionsService; 10 | 11 | beforeAll(async () => { 12 | const module = await Test.createTestingModule({ 13 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 14 | }).compile(); 15 | service = module.get(TransactionsService); 16 | }); 17 | it('should be defined', () => { 18 | expect(service).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/api/orders/transactions/transactions.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from "@nestjs/common"; 2 | import { Transactions } from "shopify-admin-api"; 3 | import { IShopifyConnect } from "../../../auth/interfaces"; 4 | import { Interfaces } from "shopify-admin-api"; 5 | import { 6 | TransactionDocument, 7 | IShopifySyncTransactionCountOptions, 8 | IShopifySyncTransactionGetOptions, 9 | IShopifySyncTransactionListOptions, 10 | } from "../../interfaces"; 11 | import { ShopifyModuleOptions, Resource } from "../../../interfaces"; 12 | import { SHOPIFY_MODULE_OPTIONS } from "../../../shopify.constants"; 13 | import { Model } from "mongoose"; 14 | import { getDiff, shopifyRetry } from "../../../helpers"; 15 | import { ShopifyApiChildCountableService } from "../../shopify-api-child-countable.service"; 16 | import { EventService } from "../../../event.service"; 17 | 18 | @Injectable() 19 | export class TransactionsService extends ShopifyApiChildCountableService< 20 | Interfaces.Transaction, 21 | Transactions, 22 | IShopifySyncTransactionCountOptions, 23 | IShopifySyncTransactionGetOptions, 24 | IShopifySyncTransactionListOptions 25 | > { 26 | resourceName: Resource = "transactions"; 27 | subResourceNames: Resource[] = []; 28 | 29 | constructor( 30 | @Inject("TransactionModelToken") 31 | private readonly transactionModel: ( 32 | shopName: string 33 | ) => Model, 34 | private readonly eventService: EventService, 35 | @Inject(SHOPIFY_MODULE_OPTIONS) 36 | protected readonly shopifyModuleOptions: ShopifyModuleOptions 37 | ) { 38 | super(transactionModel, Transactions, eventService, shopifyModuleOptions); 39 | } 40 | 41 | public async getFromShopify( 42 | user: IShopifyConnect, 43 | order_id: number, 44 | id: number, 45 | options: IShopifySyncTransactionGetOptions = {} 46 | ): Promise { 47 | const transactions = new Transactions( 48 | user.myshopify_domain, 49 | user.accessToken 50 | ); 51 | const syncToDb = options && options.syncToDb; 52 | 53 | const transaction = await shopifyRetry(() => { 54 | return transactions.get(order_id, id); 55 | }); 56 | 57 | if ( 58 | this.shopifyModuleOptions.sync.enabled && 59 | this.shopifyModuleOptions.sync.autoSyncResources.includes( 60 | this.resourceName 61 | ) 62 | ) { 63 | await this.updateOrCreateInApp(user, "id", transaction, syncToDb); 64 | } 65 | 66 | return transaction; 67 | } 68 | 69 | public async countFromShopify( 70 | user: IShopifyConnect, 71 | orderId: number 72 | ): Promise { 73 | const transactions = new Transactions( 74 | user.myshopify_domain, 75 | user.accessToken 76 | ); 77 | return await transactions.count(orderId); 78 | } 79 | 80 | public async diffSynced( 81 | user: IShopifyConnect, 82 | order_id: number 83 | ): Promise { 84 | const fromDb = await this.listFromDb(user); 85 | const fromShopify = await this.listFromShopify(user, order_id); 86 | let dbObj; 87 | return fromShopify 88 | .map( 89 | (obj) => 90 | (dbObj = fromDb.find((x) => { 91 | // FIXME: should not be necessary to use "toString", as both should be integers. Something must be wrong in the transactionModel definition (Document, DocumentType) 92 | return x.id.toString() === obj.id.toString(); 93 | }) && { 94 | [obj.id]: getDiff(obj, dbObj).filter((x) => { 95 | return x.operation !== "update" && !x.path.endsWith("._id"); 96 | }), 97 | }) 98 | ) 99 | .reduce((a, c) => ({ ...a, ...c }), {}); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/api/pages/pages.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { PagesController } from './pages.controller'; 3 | 4 | import { ShopifyModule } from '../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('Pages Controller', () => { 9 | let module: TestingModule; 10 | beforeAll(async () => { 11 | module = await Test.createTestingModule({ 12 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 13 | }).compile(); 14 | }); 15 | it('should be defined', () => { 16 | const controller: PagesController = module.get(PagesController); 17 | expect(controller).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/api/pages/pages.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { PagesService } from './pages.service'; 3 | 4 | import { ShopifyModule } from '../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('PagesService', () => { 9 | let service: PagesService; 10 | beforeAll(async () => { 11 | const module = await Test.createTestingModule({ 12 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 13 | }).compile(); 14 | service = module.get(PagesService); 15 | }); 16 | it('should be defined', () => { 17 | expect(service).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/api/products/products.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ProductsController } from './products.controller'; 3 | import { ShopifyModule } from '../../shopify.module'; 4 | import { config, mongooseConnectionPromise } from '../../../test/config.test'; 5 | import * as passport from 'passport'; 6 | 7 | describe('Products Controller', () => { 8 | let module: TestingModule; 9 | // let shopifyConnectService: ShopifyConnectService; 10 | // let productsController: ProductsController; 11 | // let productsService: ProductsService; 12 | // let user: IShopifyConnect; 13 | 14 | beforeAll(async () => { 15 | module = await Test.createTestingModule({ 16 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 17 | }).compile(); 18 | 19 | // shopifyConnectService = module.get(ShopifyConnectService); 20 | // productsController = module.get(ProductsController); 21 | // productsService = module.get(ProductsService); 22 | 23 | // user = await shopifyConnectService.findByDomain('jewelberry-dev.myshopify.com'); 24 | }); 25 | 26 | it('should be defined', () => { 27 | const controller: ProductsController = module.get(ProductsController); 28 | expect(controller).toBeDefined(); 29 | }); 30 | 31 | }); 32 | -------------------------------------------------------------------------------- /src/api/products/products.gateway.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SubscribeMessage, 3 | WebSocketGateway, 4 | WsResponse, 5 | OnGatewayInit, 6 | OnGatewayConnection, 7 | OnGatewayDisconnect, 8 | WebSocketServer, 9 | } from "@nestjs/websockets"; 10 | import { Namespace } from "socket.io"; 11 | import { Observable } from "rxjs"; 12 | import { IShopifySyncProductListOptions } from "../interfaces"; 13 | import { ProductsService } from "./products.service"; 14 | import { Interfaces } from "shopify-admin-api"; 15 | import { DebugService } from "../../debug.service"; 16 | import { IShopifyConnect, SessionSocket } from "../../interfaces"; 17 | 18 | @WebSocketGateway({ namespace: "/socket.io/shopify/api/products" }) 19 | export class ProductsGateway 20 | implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect 21 | { 22 | @WebSocketServer() server: Namespace; 23 | 24 | protected logger = new DebugService(`shopify:${this.constructor.name}`); 25 | 26 | constructor(protected readonly productsService: ProductsService) {} 27 | 28 | @SubscribeMessage("all") 29 | onAll( 30 | client: SessionSocket, 31 | options: IShopifySyncProductListOptions = {} 32 | ): Observable>> { 33 | const shop = client.handshake.session.currentShop; // TODO 34 | this.logger.debug("subscribe all for shop", shop); 35 | let shopifyConnect: IShopifyConnect | null = null; 36 | if (shop) { 37 | shopifyConnect = client.handshake.session[`shopify-connect-${shop}`]; 38 | } 39 | this.logger.debug( 40 | "subscribe all with shopifyConnect shop", 41 | shopifyConnect.myshopify_domain 42 | ); 43 | return this.productsService.listAllFromShopifyObservable( 44 | shopifyConnect, 45 | "all", 46 | options 47 | ); 48 | } 49 | 50 | afterInit(nsp: Namespace) { 51 | this.logger.debug("afterInit %s", nsp?.name); 52 | } 53 | 54 | handleConnection(client: SessionSocket) { 55 | this.logger.debug( 56 | "connect client-id: %d, session: %O", 57 | client.id, 58 | client.handshake.session 59 | ); 60 | } 61 | 62 | handleDisconnect(client: SessionSocket) { 63 | this.logger.debug("disconnect client-id: %d", client.id); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/api/search/search.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SearchController } from './search.controller'; 3 | 4 | import { ShopifyModule } from '../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('Search Controller', () => { 9 | let module: TestingModule; 10 | 11 | beforeAll(async () => { 12 | module = await Test.createTestingModule({ 13 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 14 | }).compile(); 15 | }); 16 | it('should be defined', () => { 17 | const controller: SearchController = module.get(SearchController); 18 | expect(controller).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/api/search/search.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from "@nestjs/common"; 2 | 3 | @Controller("search") 4 | export class SearchController {} 5 | -------------------------------------------------------------------------------- /src/api/search/search.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { SearchService } from './search.service'; 3 | 4 | import { ShopifyModule } from '../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('SearchService', () => { 9 | let service: SearchService; 10 | 11 | beforeAll(async () => { 12 | const module = await Test.createTestingModule({ 13 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 14 | }).compile(); 15 | 16 | service = module.get(SearchService); 17 | }); 18 | it('should be defined', () => { 19 | expect(service).toBeDefined(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/api/search/search.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | 3 | /** 4 | * Search api service 5 | */ 6 | @Injectable() 7 | export class SearchService {} 8 | -------------------------------------------------------------------------------- /src/api/shopify-api-child-countable.service.ts: -------------------------------------------------------------------------------- 1 | // Third party 2 | import { Infrastructure, Options } from "shopify-admin-api"; 3 | // import * as pRetry from 'p-retry'; 4 | import { shopifyRetry } from "../helpers"; 5 | import { Document, DocumentDefinition } from "mongoose"; 6 | 7 | import { IShopifyConnect } from "../auth/interfaces"; 8 | import { 9 | ISyncOptions, 10 | ShopifyBaseObjectType, 11 | ChildCount, 12 | ChildGet, 13 | ChildList, 14 | } from "./interfaces"; 15 | import { deleteUndefinedProperties } from "../helpers"; 16 | import { ShopifyApiChildService } from "./shopify-api-child.service"; 17 | 18 | export abstract class ShopifyApiChildCountableService< 19 | ShopifyObjectType extends ShopifyBaseObjectType, 20 | ShopifyModelClass extends Infrastructure.BaseService & 21 | ChildCount & 22 | ChildGet & 23 | ChildList, 24 | CountOptions, 25 | GetOptions extends ISyncOptions = ISyncOptions, 26 | ListOptions extends CountOptions & 27 | ISyncOptions & 28 | Options.BasicListOptions = CountOptions & 29 | ISyncOptions & 30 | Options.BasicListOptions, 31 | DatabaseDocumentType extends Document = DocumentDefinition & 32 | Document 33 | > extends ShopifyApiChildService< 34 | ShopifyObjectType, 35 | ShopifyModelClass, 36 | GetOptions, 37 | ListOptions, 38 | DatabaseDocumentType 39 | > { 40 | public async countFromShopify( 41 | shopifyConnect: IShopifyConnect, 42 | parentId: number 43 | ): Promise; 44 | public async countFromShopify( 45 | shopifyConnect: IShopifyConnect, 46 | parentId: number, 47 | options?: CountOptions 48 | ): Promise { 49 | const shopifyModel = new this.ShopifyModel( 50 | shopifyConnect.myshopify_domain, 51 | shopifyConnect.accessToken 52 | ); 53 | // Delete undefined options 54 | deleteUndefinedProperties(options); 55 | return shopifyRetry(() => { 56 | return shopifyModel.count(parentId, options); 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/api/smart-collections/smart-collections.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SmartCollectionsController } from './smart-collections.controller'; 3 | 4 | import { ShopifyModule } from '../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('SmartCollections Controller', () => { 9 | let module: TestingModule; 10 | 11 | beforeAll(async () => { 12 | module = await Test.createTestingModule({ 13 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 14 | }).compile(); 15 | }); 16 | it('should be defined', () => { 17 | const controller: SmartCollectionsController = module.get(SmartCollectionsController); 18 | expect(controller).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/api/smart-collections/smart-collections.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { SmartCollectionsService } from './smart-collections.service'; 3 | 4 | import { ShopifyModule } from '../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('SmartCollectionsService', () => { 9 | let service: SmartCollectionsService; 10 | 11 | beforeAll(async () => { 12 | const module = await Test.createTestingModule({ 13 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 14 | }).compile(); 15 | service = module.get(SmartCollectionsService); 16 | }); 17 | it('should be defined', () => { 18 | expect(service).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/api/smart-collections/smart-collections.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from "@nestjs/common"; 2 | import { SmartCollections, Interfaces } from "shopify-admin-api"; // https://github.com/ArtCodeStudio/shopify-admin-api 3 | import { IShopifyConnect } from "../../auth/interfaces/connect"; 4 | import { 5 | SmartCollectionDocument, 6 | IListAllCallbackData, 7 | IShopifySyncSmartCollectionListOptions, 8 | IShopifySyncSmartCollectionCountOptions, 9 | IShopifySyncSmartCollectionGetOptions, 10 | } from "../interfaces"; 11 | import { 12 | SyncProgressDocument, 13 | SubSyncProgressDocument, 14 | IStartSyncOptions, 15 | ShopifyModuleOptions, 16 | Resource, 17 | } from "../../interfaces"; 18 | import { Model } from "mongoose"; 19 | import { EventService } from "../../event.service"; 20 | import { ShopifyApiRootCountableService } from "../shopify-api-root-countable.service"; 21 | import { SHOPIFY_MODULE_OPTIONS } from "../../shopify.constants"; 22 | 23 | @Injectable() 24 | export class SmartCollectionsService extends ShopifyApiRootCountableService< 25 | Interfaces.SmartCollection, // ShopifyObjectType 26 | SmartCollections, // ShopifyModelClass 27 | IShopifySyncSmartCollectionCountOptions, // CountOptions 28 | IShopifySyncSmartCollectionGetOptions, // GetOptions 29 | IShopifySyncSmartCollectionListOptions, // ListOptions 30 | SmartCollectionDocument // DatabaseDocumentType 31 | > { 32 | resourceName: Resource = "smartCollections"; 33 | subResourceNames: Resource[] = []; 34 | 35 | constructor( 36 | @Inject("SmartCollectionModelToken") 37 | private readonly smartCollectionModel: ( 38 | shopName: string 39 | ) => Model, 40 | @Inject("SyncProgressModelToken") 41 | private readonly syncProgressModel: Model, 42 | private readonly eventService: EventService, 43 | @Inject(SHOPIFY_MODULE_OPTIONS) 44 | protected readonly shopifyModuleOptions: ShopifyModuleOptions 45 | ) { 46 | super( 47 | smartCollectionModel, 48 | SmartCollections, 49 | eventService, 50 | syncProgressModel, 51 | shopifyModuleOptions 52 | ); 53 | } 54 | 55 | /** 56 | * 57 | * @param shopifyConnect 58 | * @param subProgress 59 | * @param options 60 | * @param data 61 | */ 62 | async syncedDataCallback( 63 | shopifyConnect: IShopifyConnect, 64 | progress: SyncProgressDocument, 65 | subProgress: SubSyncProgressDocument, 66 | options: IStartSyncOptions, 67 | data: IListAllCallbackData 68 | ) { 69 | const products = data.data; 70 | subProgress.syncedCount += products.length; 71 | const lastProduct = products[products.length - 1]; 72 | subProgress.lastId = lastProduct.id; 73 | subProgress.info = lastProduct.title; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/api/themes/assets/assets.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AssetsController } from './assets.controller'; 3 | 4 | import { ShopifyModule } from '../../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('Assets Controller', () => { 9 | let module: TestingModule; 10 | beforeAll(async () => { 11 | module = await Test.createTestingModule({ 12 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 13 | }).compile(); 14 | }); 15 | it('should be defined', () => { 16 | const controller: AssetsController = module.get(AssetsController); 17 | expect(controller).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/api/themes/assets/assets.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { AssetsService } from './assets.service'; 3 | 4 | import { ShopifyModule } from '../../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('AssetsService', () => { 9 | let service: AssetsService; 10 | beforeAll(async () => { 11 | const module = await Test.createTestingModule({ 12 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 13 | }).compile(); 14 | service = module.get(AssetsService); 15 | }); 16 | it('should be defined', () => { 17 | expect(service).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/api/themes/assets/assets.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from "@nestjs/common"; 2 | import { Options, Interfaces, Assets } from "shopify-admin-api"; 3 | import { 4 | AssetDocument, 5 | IAppAsset, 6 | IAppAssetListOptions, 7 | } from "../../interfaces"; 8 | import { IShopifyConnect } from "../../../auth/interfaces/connect"; 9 | import { Model } from "mongoose"; 10 | import { DebugService } from "../../../debug.service"; 11 | 12 | @Injectable() 13 | export class AssetsService { 14 | logger = new DebugService(`shopify:${this.constructor.name}`); 15 | 16 | constructor( 17 | @Inject("AssetModelToken") 18 | private readonly assetModel: Model 19 | ) {} 20 | 21 | // https://stackoverflow.com/a/273810/1465919 22 | // https://stackoverflow.com/a/5440771/1465919 23 | private regexIndexOf(text: string, re: RegExp, startRegex: boolean) { 24 | if (startRegex) { 25 | return text.search(re); 26 | } else { 27 | const match = text.match(re); 28 | if (match && match[0]) { 29 | return text.indexOf(match[0]) + match[0].length; 30 | } 31 | } 32 | } 33 | 34 | private parseSection(asset: IAppAsset) { 35 | const startSchema = this.regexIndexOf( 36 | asset.value, 37 | /{%\s*?schema\s*?%}/gm, 38 | false 39 | ); 40 | const endSchema = this.regexIndexOf( 41 | asset.value, 42 | /{%\s*?endschema\s*?%}/gm, 43 | true 44 | ); 45 | const startLiquid = 0; 46 | const endLiquid = this.regexIndexOf( 47 | asset.value, 48 | /{%\s*?endschema\s*?%}/gm, 49 | true 50 | ); 51 | // this.logger.debug(`startSchema: ${startSchema} endSchema: ${endSchema}`); 52 | if (startSchema >= 0 && endSchema >= 0) { 53 | const sectionSchemaString = asset.value 54 | .substring(startSchema, endSchema) 55 | .trim(); 56 | const sectionLiquidString = asset.value 57 | .substring(startLiquid, endLiquid) 58 | .trim(); 59 | let sectionSchema; 60 | try { 61 | sectionSchema = asset.value = JSON.parse(sectionSchemaString); 62 | } catch (error) { 63 | this.logger.error(error, sectionSchemaString); 64 | // if parse json fails return normal asset file 65 | return asset; 66 | } 67 | // json is just the schema stuff 68 | asset.json = sectionSchema; 69 | 70 | // value ins only the liquid stuff 71 | asset.value = sectionLiquidString; 72 | } 73 | return asset; 74 | } 75 | 76 | async list( 77 | user: IShopifyConnect, 78 | id: number, 79 | options: IAppAssetListOptions = {} 80 | ): Promise { 81 | const assets = new Assets(user.myshopify_domain, user.accessToken); 82 | return assets.list(id, options).then((assetData) => { 83 | // this.logger.debug('assetData: %O', assetData); 84 | assetData = assetData.filter((asset) => { 85 | let matches = true; 86 | if ( 87 | options.content_type && 88 | options.content_type !== asset.content_type 89 | ) { 90 | matches = false; 91 | } 92 | 93 | if ( 94 | options.key_starts_with && 95 | !asset.key.startsWith(options.key_starts_with) 96 | ) { 97 | matches = false; 98 | } 99 | return matches; 100 | }); 101 | return assetData; 102 | }); 103 | } 104 | 105 | async get( 106 | user: IShopifyConnect, 107 | id: number, 108 | key: string, 109 | options: Options.FieldOptions = {} 110 | ) { 111 | const assets = new Assets(user.myshopify_domain, user.accessToken); 112 | return assets.get(id, key, options).then((assetData: IAppAsset) => { 113 | // this.logger.debug(`assetData: %O`, assetData); 114 | if (assetData.content_type === "application/json") { 115 | assetData.json = JSON.parse(assetData.value); 116 | } 117 | 118 | if (assetData.content_type === "text/x-liquid") { 119 | if (assetData.key.startsWith("sections/")) { 120 | assetData = this.parseSection(assetData); 121 | } 122 | } 123 | return assetData; 124 | }); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/api/themes/locales/locales.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { LocalesController } from './locales.controller'; 3 | 4 | import { ShopifyModule } from '../../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('Locales Controller', () => { 9 | let module: TestingModule; 10 | beforeAll(async () => { 11 | module = await Test.createTestingModule({ 12 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 13 | }).compile(); 14 | }); 15 | it('should be defined', () => { 16 | const controller: LocalesController = module.get(LocalesController); 17 | expect(controller).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/api/themes/locales/locales.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { LocalesService } from './locales.service'; 3 | 4 | import { ShopifyModule } from '../../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('LocalesService', () => { 9 | let service: LocalesService; 10 | beforeAll(async () => { 11 | const module = await Test.createTestingModule({ 12 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 13 | }).compile(); 14 | service = module.get(LocalesService); 15 | }); 16 | it('should be defined', () => { 17 | expect(service).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/api/themes/themes.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ThemesController } from './themes.controller'; 3 | 4 | import { ShopifyModule } from '../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('Themes Controller', () => { 9 | let module: TestingModule; 10 | beforeAll(async () => { 11 | module = await Test.createTestingModule({ 12 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 13 | }).compile(); 14 | }); 15 | it('should be defined', () => { 16 | const controller: ThemesController = module.get(ThemesController); 17 | expect(controller).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/api/themes/themes.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Param, 4 | UseGuards, 5 | Req, 6 | Get, 7 | HttpStatus, 8 | HttpException, 9 | } from "@nestjs/common"; 10 | import { Roles } from "../../guards/roles.decorator"; 11 | import { DebugService } from "./../../debug.service"; 12 | import { ThemesService } from "./themes.service"; 13 | import { ShopifyApiGuard } from "../../guards/shopify-api.guard"; 14 | import { IUserRequest } from "../../interfaces/user-request"; 15 | 16 | @Controller("shopify/api/themes") 17 | export class ThemesController { 18 | logger = new DebugService(`shopify:${this.constructor.name}`); 19 | 20 | constructor(protected readonly themesService: ThemesService) {} 21 | 22 | @UseGuards(ShopifyApiGuard) 23 | @Roles("shopify-staff-member") 24 | @Get() 25 | getThemes(@Req() req: IUserRequest) { 26 | const shop = req.session.currentShop || req.shop; 27 | return this.themesService 28 | .listFromShopify(req.session[`user-${shop}`]) 29 | .catch((error: Error) => { 30 | throw new HttpException( 31 | error.message, 32 | HttpStatus.INTERNAL_SERVER_ERROR 33 | ); 34 | }); 35 | } 36 | 37 | @UseGuards(ShopifyApiGuard) 38 | @Roles("shopify-staff-member") 39 | @Get("active") 40 | getActiveTheme(@Req() req) { 41 | const shop = req.session.currentShop || req.shop; 42 | return this.themesService 43 | .getActive(req.session[`user-${shop}`]) 44 | .catch((error: Error) => { 45 | this.logger.error(error); 46 | throw new HttpException( 47 | error.message, 48 | HttpStatus.INTERNAL_SERVER_ERROR 49 | ); 50 | }); 51 | } 52 | 53 | @UseGuards(ShopifyApiGuard) 54 | @Roles("shopify-staff-member") 55 | @Get(":theme_id") 56 | getTheme(@Param("theme_id") themeId: number, @Req() req) { 57 | const shop = req.session.currentShop || req.shop; 58 | return this.themesService 59 | .getFromShopify(req.session[`user-${shop}`], themeId) 60 | .catch((error: Error) => { 61 | this.logger.error(error); 62 | throw new HttpException( 63 | error.message, 64 | HttpStatus.INTERNAL_SERVER_ERROR 65 | ); 66 | }); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/api/themes/themes.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { ThemesService } from './themes.service'; 3 | 4 | import { ShopifyModule } from '../../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('ThemesService', () => { 9 | let service: ThemesService; 10 | beforeAll(async () => { 11 | const module = await Test.createTestingModule({ 12 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 13 | }).compile(); 14 | service = module.get(ThemesService); 15 | }); 16 | it('should be defined', () => { 17 | expect(service).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/api/themes/themes.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from "@nestjs/common"; 2 | import { Themes } from "shopify-admin-api"; // https://github.com/ArtCodeStudio/shopify-admin-api 3 | import { IShopifyConnect } from "../../auth/interfaces/connect"; 4 | import { Interfaces } from "shopify-admin-api"; 5 | import { ThemeDocument } from "../interfaces/mongoose/theme.schema"; 6 | import { Model } from "mongoose"; 7 | import { ShopifyApiRootService } from "../shopify-api-root.service"; 8 | import { EventService } from "../../event.service"; 9 | 10 | import { 11 | SyncProgressDocument, 12 | ShopifyModuleOptions, 13 | Resource, 14 | } from "../../interfaces"; 15 | import { 16 | IShopifySyncThemeGetOptions, 17 | IShopifySyncThemeListOptions, 18 | IAppThemeListOptions, 19 | IAppThemeListFilter, 20 | } from "../interfaces"; 21 | import { SHOPIFY_MODULE_OPTIONS } from "../../shopify.constants"; 22 | 23 | @Injectable() 24 | export class ThemesService extends ShopifyApiRootService< 25 | Interfaces.Theme, // ShopifyObjectType 26 | Themes, // ShopifyModelClass 27 | IShopifySyncThemeGetOptions, // GetOptions 28 | IShopifySyncThemeListOptions, // ListOptions 29 | ThemeDocument // DatabaseDocumentType 30 | > { 31 | resourceName: Resource = "themes"; 32 | subResourceNames: Resource[] = ["assets"]; 33 | 34 | constructor( 35 | @Inject("ThemeModelToken") 36 | private readonly themeModel: (shopName: string) => Model, 37 | private readonly eventService: EventService, 38 | @Inject("SyncProgressModelToken") 39 | private readonly syncProgressModel: Model, 40 | @Inject(SHOPIFY_MODULE_OPTIONS) 41 | protected readonly shopifyModuleOptions: ShopifyModuleOptions 42 | ) { 43 | super( 44 | themeModel, 45 | Themes, 46 | eventService, 47 | syncProgressModel, 48 | shopifyModuleOptions 49 | ); 50 | } 51 | 52 | /** 53 | * Retrieves a list of themes. 54 | * @param user 55 | * @param options Show only certain fields, specified by a comma-separated list of field names. 56 | * @param filter Filter the list by property 57 | * 58 | * @see https://help.shopify.com/en/api/reference/online-store/theme#index 59 | */ 60 | public async listFromShopify( 61 | shopifyConnect: IShopifyConnect, 62 | options?: IAppThemeListOptions, 63 | filter?: IAppThemeListFilter 64 | ): Promise[]> { 65 | return super.listFromShopify(shopifyConnect, options).then((themes) => { 66 | if (!filter) { 67 | return themes; 68 | } else { 69 | return themes.filter((theme) => { 70 | let matches = true; 71 | for (const key in filter) { 72 | if (filter[key]) { 73 | matches = matches && theme[key] === filter[key]; 74 | } 75 | } 76 | return matches; 77 | }); 78 | } 79 | }); 80 | } 81 | 82 | /** 83 | * Retrieves the single active theme or null if no theme is active 84 | * @param user 85 | * @param id theme id 86 | * @see https://help.shopify.com/en/api/reference/online-store/theme#show 87 | */ 88 | public async getActive( 89 | user: IShopifyConnect 90 | ): Promise | null> { 91 | return this.listFromShopify(user, {}, { role: "main" }).then((themes) => { 92 | if (themes.length) { 93 | return themes[0]; 94 | } else { 95 | return null; 96 | } 97 | }); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ShopifyAuthController } from './auth.controller'; 3 | 4 | import { ShopifyModule } from '../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('Auth Controller', () => { 9 | let module: TestingModule; 10 | beforeAll(async () => { 11 | module = await Test.createTestingModule({ 12 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 13 | }).compile(); 14 | }); 15 | it('should be defined', () => { 16 | const controller: ShopifyAuthController = module.get(ShopifyAuthController); 17 | expect(controller).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ShopifyAuthService } from './auth.service'; 3 | 4 | import { ShopifyModule } from '../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('ShopifyAuthService', () => { 9 | let module: TestingModule; 10 | let service: ShopifyAuthService; 11 | beforeAll(async () => { 12 | module = await Test.createTestingModule({ 13 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 14 | }).compile(); 15 | service = module.get(ShopifyAuthService); 16 | 17 | }); 18 | it('should be defined', () => { 19 | expect(service).toBeDefined(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/auth/auth.strategy.ts: -------------------------------------------------------------------------------- 1 | import { PassportStrategy } from "@nestjs/passport"; 2 | import { Strategy } from "passport-shopify"; // https://github.com/danteata/passport-shopify 3 | import { ShopifyConnectService } from "./connect.service"; 4 | import { DebugService } from "../debug.service"; 5 | import { ShopifyAuthController } from "./auth.controller"; 6 | import { IShopifyAuthProfile } from "./interfaces/profile"; 7 | import { ShopifyModuleOptions } from "../interfaces/shopify-module-options"; 8 | import { PassportStatic } from "passport"; 9 | 10 | export class ShopifyAuthStrategy extends PassportStrategy(Strategy, "shopify") { 11 | protected logger = new DebugService("shopify:ShopifyAuthStrategy"); 12 | 13 | protected authController: ShopifyAuthController; 14 | 15 | constructor( 16 | shop: string, 17 | private shopifyConnectService: ShopifyConnectService, 18 | private readonly shopifyModuleOptions: ShopifyModuleOptions, 19 | private readonly passport: PassportStatic 20 | ) { 21 | super({ 22 | clientID: shopifyModuleOptions.shopify.clientID, 23 | clientSecret: shopifyModuleOptions.shopify.clientSecret, 24 | callbackURL: shopifyModuleOptions.shopify.callbackURL, 25 | shop, 26 | }); 27 | } 28 | 29 | /** 30 | * Verify callback method, called insite of the ShopifyAuthStrategy 31 | * 32 | * @note Do not use verifiedDone callback function, this leads to "Cannot set headers after they are sent to the client" 33 | * 34 | * @param shop 35 | * @param accessToken 36 | * @param refreshToken 37 | * @param profile 38 | * @param verifiedDone 39 | */ 40 | async validate( 41 | accessToken: string, 42 | refreshToken: string, 43 | profile: IShopifyAuthProfile /*, verifiedDone: (error?: Error | null, user?: any) => void*/ 44 | ) { 45 | this.logger.debug(`validate`); 46 | this.logger.debug(`accessToken: %s`, accessToken); 47 | this.logger.debug(`refreshToken: %s`, refreshToken); 48 | this.logger.debug(`profile.displayName: %s`, profile.displayName); 49 | 50 | return this.shopifyConnectService 51 | .connectOrUpdate(profile, accessToken) 52 | .then((user) => { 53 | if (!user) { 54 | throw new Error("Error on connect or update user"); 55 | } 56 | this.logger.debug( 57 | `validate user, user.myshopify_domain: %s`, 58 | user.myshopify_domain 59 | ); 60 | // return verifiedDone(null, user); 61 | return user; // see AuthStrategy -> serializeUser 62 | }) 63 | .catch((err) => { 64 | this.logger.error(err); 65 | // return verifiedDone(err) 66 | throw err; 67 | }); 68 | } 69 | 70 | public authenticate(req, options) { 71 | this.logger.debug(`authenticate`); 72 | try { 73 | return super.authenticate(req, options); 74 | } catch (error) { 75 | this.logger.error(error); 76 | throw error; 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/auth/connect.providers.ts: -------------------------------------------------------------------------------- 1 | import { Model, Mongoose } from "mongoose"; 2 | import { ShopifyConnectSchema } from "./connect.schema"; 3 | import { IShopifyConnectDocument } from "./interfaces/connect"; 4 | 5 | const shopifyConnectProviders = (connection: Mongoose) => { 6 | return [ 7 | { 8 | provide: "ShopifyConnectModelToken", 9 | useValue: connection.model( 10 | "shopify_connect", 11 | ShopifyConnectSchema 12 | ) as unknown as Model, 13 | }, 14 | ]; 15 | }; 16 | 17 | export { shopifyConnectProviders }; 18 | -------------------------------------------------------------------------------- /src/auth/connect.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from "mongoose"; 2 | import { ShopifyShopSchema } from "../shop/shop.schema"; 3 | 4 | export const ShopifyConnectSchema = new Schema({ 5 | shopifyID: Number, 6 | myshopify_domain: String, 7 | accessToken: String, 8 | createdAt: Date, 9 | updatedAt: Date, 10 | roles: [], 11 | shop: ShopifyShopSchema, 12 | }); 13 | -------------------------------------------------------------------------------- /src/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./auth.controller"; 2 | export * from "./auth.service"; 3 | export * from "./auth.strategy"; 4 | export * from "./connect.providers"; 5 | export * from "./connect.schema"; 6 | export * from "./connect.service"; 7 | export * from "./passport.service"; 8 | -------------------------------------------------------------------------------- /src/auth/interfaces/connect.ts: -------------------------------------------------------------------------------- 1 | import { TRoles } from "./role"; 2 | import { Document, Types } from "mongoose"; 3 | 4 | import { IShopifyShop } from "../../shop/interfaces/shop"; 5 | 6 | export interface IShopifyConnect { 7 | _id: Types.ObjectId; 8 | shopifyID: number; 9 | myshopify_domain: string; 10 | accessToken: string; 11 | createdAt: Date; 12 | updatedAt: Date; 13 | roles: TRoles; 14 | shop: IShopifyShop; 15 | } 16 | 17 | export interface IShopifyConnectDocument extends IShopifyConnect, Document { 18 | _id: Types.ObjectId; 19 | } 20 | -------------------------------------------------------------------------------- /src/auth/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./connect"; 2 | export * from "./profile"; 3 | export * from "./request-type"; 4 | export * from "./role"; 5 | -------------------------------------------------------------------------------- /src/auth/interfaces/profile.ts: -------------------------------------------------------------------------------- 1 | import { Interfaces as ShopifyInterfaces } from "shopify-admin-api"; // https://github.com/ArtCodeStudio/shopify-admin-api 2 | import { Profile } from "passport"; 3 | 4 | /** 5 | * @see http://www.passportjs.org/docs/profile/ 6 | */ 7 | export interface IShopifyAuthProfile extends Profile { 8 | provider: "shopify"; 9 | _raw: string; 10 | _json: { 11 | shop: ShopifyInterfaces.Shop; 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /src/auth/interfaces/request-type.ts: -------------------------------------------------------------------------------- 1 | export type TRequestType = "app-backend" | "theme-client"; 2 | export type TRequestTypes = Array; 3 | -------------------------------------------------------------------------------- /src/auth/interfaces/role.ts: -------------------------------------------------------------------------------- 1 | export type TRole = "admin" | "shopify-staff-member"; 2 | export type TRoles = Array; 3 | -------------------------------------------------------------------------------- /src/auth/passport.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Inject } from "@nestjs/common"; 2 | import { ShopifyConnectService } from "./connect.service"; 3 | import { DebugService } from "../debug.service"; 4 | import { PassportStatic } from "passport"; 5 | import { IShopifyConnect } from "../interfaces/user-request"; 6 | 7 | @Injectable() 8 | export class PassportService { 9 | protected logger = new DebugService("shopify:ShopifyConnectService"); 10 | 11 | constructor( 12 | private readonly shopifyConnectService: ShopifyConnectService, 13 | @Inject("Passport") private readonly passport: PassportStatic 14 | ) { 15 | this.passport.serializeUser(this.serializeUser.bind(this)); 16 | this.passport.deserializeUser(this.deserializeUser.bind(this)); 17 | } 18 | 19 | public serializeUser(user: IShopifyConnect, done) { 20 | this.logger.debug(`serializeUser user id`, user.shopifyID); 21 | return done(null, user.shopifyID); 22 | } 23 | 24 | public deserializeUser(id: number, done) { 25 | this.logger.debug(`deserializeUser`, id); 26 | if (!id) { 27 | const error = new Error("Id not found!"); 28 | this.logger.error(error); 29 | return done(error); 30 | } 31 | this.shopifyConnectService 32 | .findByShopifyId(id) 33 | .then((user) => { 34 | this.logger.debug(`deserializeUser`, user); 35 | if (!user) { 36 | const error = new Error("User not found!"); 37 | this.logger.error(error); 38 | return done(error); 39 | } 40 | return done(null, user); 41 | }) 42 | .catch((error: Error) => { 43 | this.logger.error(error); 44 | done(error); 45 | }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/charge/charge.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ChargeController } from './charge.controller'; 3 | 4 | import { ShopifyModule } from '../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('Charge Controller', () => { 9 | let module: TestingModule; 10 | beforeAll(async () => { 11 | const mongooseConnection = await mongooseConnectionPromise; 12 | module = await Test.createTestingModule({ 13 | imports: [ShopifyModule.forRoot(config, mongooseConnection, passport)], 14 | }).compile(); 15 | }); 16 | it('should be defined', () => { 17 | const controller: ChargeController = module.get(ChargeController); 18 | expect(controller).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/charge/charge.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { ChargeService } from './charge.service'; 3 | 4 | import { ShopifyModule } from '../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('ChargeService', () => { 9 | let service: ChargeService; 10 | beforeAll(async () => { 11 | const module = await Test.createTestingModule({ 12 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 13 | }).compile(); 14 | service = module.get(ChargeService); 15 | }); 16 | it('should be defined', () => { 17 | expect(service).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/charge/interfaces/availableCharge.ts: -------------------------------------------------------------------------------- 1 | export interface IAvailableCharge { 2 | name: string; 3 | price: number; 4 | test: boolean; 5 | trial_days: number; 6 | active: boolean; 7 | } 8 | -------------------------------------------------------------------------------- /src/charge/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./availableCharge"; 2 | export * from "./plan"; 3 | -------------------------------------------------------------------------------- /src/charge/interfaces/plan.ts: -------------------------------------------------------------------------------- 1 | import { Document } from "mongoose"; 2 | 3 | export interface IPlan { 4 | name: string; 5 | price: number; 6 | test: boolean; 7 | trial_days: number; 8 | visible: boolean; 9 | return_url: string; 10 | } 11 | 12 | export interface IPlanDocument extends IPlan, Document {} 13 | -------------------------------------------------------------------------------- /src/debug.service.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "@nestjs/common"; 2 | import { debug, Debugger } from "debug"; 3 | 4 | /** 5 | * TODO use https://github.com/winstonjs/winston / https://github.com/felixge/node-stack-trace? 6 | */ 7 | export class DebugService { 8 | private namespace: string; 9 | private debugger: Debugger; 10 | constructor(namespace: string) { 11 | this.namespace = namespace; 12 | this.debugger = debug(this.namespace); 13 | } 14 | 15 | public log(...args: any[]) { 16 | Logger.log( 17 | args 18 | .map((arg) => { 19 | if (typeof arg === "object") { 20 | return JSON.stringify(arg, null, 2); 21 | } 22 | return arg; 23 | }) 24 | .join(" "), 25 | this.namespace 26 | ); 27 | } 28 | 29 | public debug(formatter: string, ...args: any[]) { 30 | // this.log(...args); 31 | this.debugger(formatter, ...args); 32 | } 33 | 34 | public warn(...args: any[]) { 35 | Logger.warn( 36 | args 37 | .map((arg) => { 38 | if (typeof arg === "object") { 39 | return JSON.stringify(arg, null, 2); 40 | } 41 | return arg; 42 | }) 43 | .join("\n"), 44 | this.namespace 45 | ); 46 | } 47 | 48 | public error(...args: any[]) { 49 | const msgs = []; 50 | const traces = []; 51 | args.forEach((arg) => { 52 | if (typeof arg === "object") { 53 | if (arg instanceof Error) { 54 | msgs.push(arg.message); 55 | traces.push(arg.stack); 56 | } else { 57 | msgs.push(JSON.stringify(arg, null, 2)); 58 | } 59 | } else { 60 | msgs.push(arg); 61 | } 62 | }); 63 | let trace = ""; 64 | if (traces.length === 1) { 65 | trace = traces[0]; 66 | } else if (traces.length > 1) { 67 | trace = traces.map((t, i) => `[${i + 1}] ${t}`).join("\n"); 68 | } 69 | Logger.error(msgs.join("\n"), trace, this.namespace); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/event.service.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { DebugService } from "./debug.service"; 3 | 4 | export class EventService extends EventEmitter { 5 | logger = new DebugService(`shopify:${this.constructor.name}`); 6 | 7 | constructor() { 8 | super(); 9 | 10 | if (process.env.NODE_ENV === "development") { 11 | ["", "success", "failed", "cancelled", "ended"].forEach((key) => { 12 | this.on(`${key ? key + ":" : ""}sync`, (shop, progress) => { 13 | this.logger.debug( 14 | `${key ? key + ":" : ""}sync: %s %s`, 15 | `${shop}:${progress.id}`, 16 | progress.shop 17 | ); 18 | }); 19 | }); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/graphql-client.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLClient as _GraphQLClient, gql } from "graphql-request"; 2 | import type { Variables, RequestDocument } from "graphql-request/dist/types"; 3 | import { promises as fs } from "fs"; 4 | import { resolve, normalize, dirname } from "path"; 5 | import findRoot = require("find-root"); 6 | 7 | export class GraphQLClient extends _GraphQLClient { 8 | protected apiVersion = "2020-10"; 9 | 10 | protected appRoot = findRoot(process.cwd()); 11 | protected moduleRoot = findRoot(dirname(require.resolve("nest-shopify"))); 12 | 13 | constructor( 14 | protected shopDomain: string, 15 | protected accessToken: string, 16 | apiVersion = "2020-10" 17 | ) { 18 | super(`https://${shopDomain}/admin/api/${apiVersion}/graphql.json`, { 19 | headers: { 20 | // "X-Shopify-Storefront-Access-Token": accessToken, 21 | "X-Shopify-Access-Token": accessToken, 22 | "Content-Type": "application/json", 23 | }, 24 | }); 25 | } 26 | 27 | async loadRequestDocument(filePath: string): Promise { 28 | const content = await fs.readFile( 29 | normalize(resolve(this.moduleRoot, filePath)), 30 | "utf8" 31 | ); 32 | return gql` 33 | ${content} 34 | `; 35 | } 36 | 37 | /** 38 | * Execute a server-side GraphQL query within the given context. 39 | * @param options 40 | * @param queryFilePath 41 | */ 42 | async execute(actionFilePath: string, variables?: Variables) { 43 | const action = await this.loadRequestDocument(actionFilePath); 44 | const data = await this.request(action, variables); 45 | return data; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./request.decorator"; 2 | export * from "./request.guard"; 3 | export * from "./roles.decorator"; 4 | export * from "./roles.guard"; 5 | export * from "./shopify-api.guard"; 6 | -------------------------------------------------------------------------------- /src/guards/request.decorator.ts: -------------------------------------------------------------------------------- 1 | import { TRequestTypes } from "../auth/interfaces/request-type"; 2 | 3 | /** 4 | * Decorator for RequestGuard, use this with @Request('app-backend') or @Request('theme-client') 5 | */ 6 | export const Request = (types: TRequestTypes): MethodDecorator => { 7 | return (target: any) => { 8 | Reflect.defineMetadata("request", types, target); // TODO NEST7 CHECKME 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /src/guards/request.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; 2 | import { Observable } from "rxjs"; 3 | import { Reflector } from "@nestjs/core"; 4 | 5 | import { IUserRequest } from "../interfaces/user-request"; 6 | import { Session } from "../interfaces/session"; 7 | import { TRequestTypes } from "../auth/interfaces/request-type"; 8 | import { DebugService } from "../debug.service"; 9 | import { ShopifyAuthService } from "../auth/auth.service"; 10 | import { SessionSocket } from "../interfaces/session-socket"; 11 | 12 | /** 13 | * Guard to check where the request comes from (an registed shopify theme or the app backend) 14 | */ 15 | @Injectable() 16 | export class RequestGuard implements CanActivate { 17 | protected logger = new DebugService("shopify:RequestGuard"); 18 | 19 | constructor( 20 | private readonly reflector: Reflector, 21 | private readonly shopifyAuthService: ShopifyAuthService 22 | ) {} 23 | 24 | canActivate( 25 | context: ExecutionContext 26 | ): boolean | Promise | Observable { 27 | // this.logger.debug('context', context); 28 | // TODO NEST7 CHECKME 29 | const types = this.reflector.get( 30 | "request", 31 | context.getHandler() 32 | ); 33 | const request = context.switchToHttp().getRequest() as IUserRequest; 34 | // this.logger.debug('request', request); 35 | 36 | // Check if request is really a http request 37 | if (request.app) { 38 | return this.validateRequest(request, types); 39 | } 40 | 41 | const client = context.switchToWs().getClient(); 42 | // this.logger.debug('client', client); 43 | 44 | // Check if client is really a socket client 45 | if (client.handshake) { 46 | return this.validateClient(client, types); 47 | } 48 | } 49 | 50 | hasType(session: Session, types: TRequestTypes) { 51 | let hasType = false; 52 | types.forEach((type) => { 53 | if (type === "app-backend" && session.isAppBackendRequest) { 54 | hasType = true; 55 | } 56 | if (type === "theme-client" && session.isThemeClientRequest) { 57 | hasType = true; 58 | } 59 | }); 60 | return hasType; 61 | } 62 | 63 | validateRequest(request: IUserRequest, types?: TRequestTypes) { 64 | // if no type are passt using @Request('app-backend') this route do not need any role so activate this with true 65 | if (!types || types.length === 0) { 66 | return true; 67 | } 68 | 69 | return this.hasType(request.session, types); 70 | } 71 | 72 | validateClient(client: SessionSocket, types?: TRequestTypes) { 73 | // if no types are passt using @Request('app-backend') this route do not need any role so activate this with true 74 | if (!types || types.length === 0) { 75 | return true; 76 | } 77 | 78 | return this.hasType(client.handshake.session, types); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/guards/roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { TRoles } from "../auth/interfaces/role"; 2 | 3 | /** 4 | * Decorator for RolesGuard, use this with @Roles('admin') or @Roles('shopify-staff-member') 5 | * @param roles 6 | */ 7 | export const Roles = (...roles: TRoles): MethodDecorator => { 8 | return (target: any) => { 9 | return Reflect.defineMetadata("roles", roles, target); 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /src/guards/roles.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; 2 | import { Observable } from "rxjs"; 3 | import { Reflector } from "@nestjs/core"; 4 | 5 | import { IUserRequest, IShopifyConnect } from "../interfaces/user-request"; 6 | import { TRoles } from "../auth/interfaces/role"; 7 | import { DebugService } from "../debug.service"; 8 | import { ShopifyAuthService } from "../auth/auth.service"; 9 | import { SessionSocket } from "../interfaces/session-socket"; 10 | 11 | /** 12 | * Guard to check the backend user roles 13 | */ 14 | @Injectable() 15 | export class RolesGuard implements CanActivate { 16 | protected logger = new DebugService("shopify:RolesGuard"); 17 | 18 | constructor( 19 | private readonly reflector: Reflector, 20 | private readonly shopifyAuthService: ShopifyAuthService 21 | ) {} 22 | 23 | canActivate( 24 | context: ExecutionContext 25 | ): boolean | Promise | Observable { 26 | // this.logger.debug('context', context); 27 | const roles = this.reflector.get("roles", context.getHandler()); 28 | const request = context.switchToHttp().getRequest() as IUserRequest; 29 | // this.logger.debug('request', request); 30 | 31 | // Check if request is really a http request 32 | if (request.app) { 33 | return this.validateRequest(request, roles); 34 | } 35 | 36 | const client = context.switchToWs().getClient(); 37 | // this.logger.debug('client', client); 38 | 39 | // Check if client is really a socket client 40 | if (client.handshake) { 41 | return this.validateClient(client, roles); 42 | } 43 | } 44 | 45 | hasRole(user: IShopifyConnect, roles: TRoles) { 46 | // this.logger.debug('hasRole', roles, user.roles); 47 | const hasRoule = user.roles.some((role) => { 48 | // this.logger.debug('hasRole role', role); 49 | return roles.includes(role); 50 | }); 51 | this.logger.debug("hasRole result", hasRoule); 52 | return hasRoule; 53 | } 54 | 55 | validateRequest(req: IUserRequest, roles?: TRoles) { 56 | // if no roles are passtthis route do not need any role so activate this with true 57 | if (!roles || roles.length === 0) { 58 | return true; 59 | } 60 | 61 | // Only logged in users can have any role 62 | if (!req.session.isLoggedInToAppBackend) { 63 | return false; 64 | } 65 | 66 | // DO NOT USE req.session[`shopify-connect-${req.shop}`] because this can always be set on theme-client requests 67 | if (!this.hasRole(req.session[`user-${req.shop}`], roles)) { 68 | return false; 69 | } 70 | return true; 71 | } 72 | 73 | validateClient(client: SessionSocket, roles?: TRoles) { 74 | // if no roles are passt this route do not need any role so activate this with true 75 | if (!roles || roles.length === 0) { 76 | return true; 77 | } 78 | 79 | // Only logged in users can have any role 80 | if (!client.handshake.session.isLoggedInToAppBackend) { 81 | return false; 82 | } 83 | 84 | // DO NOT USE request.session.user because this can always be set on theme requests 85 | if ( 86 | !this.hasRole( 87 | client.handshake.session[ 88 | `user-${client.handshake.session.currentShop}` 89 | ], 90 | roles 91 | ) 92 | ) { 93 | return false; 94 | } 95 | 96 | return true; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/guards/shopify-api.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | Injectable, 5 | Inject, 6 | } from "@nestjs/common"; 7 | import { Observable } from "rxjs"; 8 | 9 | import { IUserRequest } from "../interfaces/user-request"; 10 | import { ShopifyConnectService } from "../auth/connect.service"; 11 | import { ShopifyAuthService } from "../auth/auth.service"; 12 | import { SessionSocket, IShopifyConnect } from "../interfaces"; 13 | import { DebugService } from "../debug.service"; 14 | 15 | /** 16 | * 17 | */ 18 | @Injectable() 19 | class ShopifyApiGuard implements CanActivate { 20 | protected logger = new DebugService("shopify:ShopifyApiGuard"); 21 | 22 | constructor( 23 | @Inject(ShopifyConnectService) 24 | private readonly shopifyConnectService: ShopifyConnectService, 25 | @Inject(ShopifyAuthService) 26 | private readonly shopifyAuthService: ShopifyAuthService 27 | ) {} 28 | 29 | canActivate( 30 | context: ExecutionContext 31 | ): boolean | Promise | Observable { 32 | // this.logger.debug('context', context); 33 | const request = context.switchToHttp().getRequest() as IUserRequest; 34 | // this.logger.debug('request', request); 35 | // Check if request is really a http request 36 | if (request.app) { 37 | return this.validateRequest(request); 38 | } 39 | 40 | const client = context.switchToWs().getClient(); 41 | // this.logger.debug('client', client); 42 | // Check if client is really a socket client 43 | if (client.handshake) { 44 | return this.validateClient(client); 45 | } 46 | } 47 | 48 | /** 49 | * @param request Validate http request 50 | */ 51 | validateRequest(req: IUserRequest) { 52 | // See get-shopify-connect.middleware.ts 53 | if (req.session[`shopify-connect-${req.shop}`]) { 54 | return true; 55 | } 56 | return false; 57 | } 58 | 59 | /** 60 | * Uses https://github.com/oskosk/express-socket.io-session to get the session from handshake 61 | * @param client Validate websocket request 62 | */ 63 | validateClient(client: SessionSocket) { 64 | const shop = client.handshake.session.currentShop; 65 | let shopifyConnect: IShopifyConnect; 66 | if (shop) { 67 | shopifyConnect = client.handshake.session[`shopify-connect-${shop}`]; 68 | } 69 | if (!shopifyConnect) { 70 | shopifyConnect = client.handshake.session.shopifyConnect; // DEPRECATED 71 | } 72 | 73 | if (shopifyConnect) { 74 | return true; 75 | } 76 | 77 | return false; 78 | } 79 | } 80 | 81 | export { ShopifyApiGuard }; 82 | -------------------------------------------------------------------------------- /src/helpers/chunk-array.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns an array with arrays of the given size. 3 | * 4 | * @param arr array to split 5 | * @param chunkSize Size of every group 6 | */ 7 | export function chunkArray(arr: Array, chunkSize: number) { 8 | const tempArray = new Array(); 9 | 10 | for (let i = 0; i < arr.length; i += chunkSize) { 11 | const myChunk = arr.slice(i, i + chunkSize); 12 | // Do something if you want with the group 13 | tempArray.push(myChunk); 14 | } 15 | 16 | return tempArray; 17 | } 18 | -------------------------------------------------------------------------------- /src/helpers/delete-undefined-properties.ts: -------------------------------------------------------------------------------- 1 | export function deleteUndefinedProperties(object: any) { 2 | // Delete undefined properties 3 | if (object) { 4 | for (const key in object) { 5 | if (object.hasOwnProperty(key)) { 6 | if (typeof object[key] === "undefined") { 7 | delete object[key]; 8 | } 9 | } 10 | } 11 | } 12 | return object; 13 | } 14 | -------------------------------------------------------------------------------- /src/helpers/diff.ts: -------------------------------------------------------------------------------- 1 | export type DiffType = 2 | | "string" 3 | | "number" 4 | | "boolean" 5 | | "symbol" 6 | | "undefined" 7 | | "object" 8 | | "function" 9 | | "array" 10 | | "date" 11 | | "bigint"; 12 | 13 | export interface Change { 14 | path: string; 15 | operation: string; 16 | value?: any; 17 | } 18 | 19 | /** 20 | * Get diff of two objects 21 | */ 22 | export function getDiff(a: any, b: any): Array { 23 | const changes: Array = []; 24 | let bType: DiffType = typeof b; 25 | let aType: DiffType = typeof a; 26 | if (bType === "object") { 27 | if (Array.isArray(b)) { 28 | bType = "array"; 29 | } else if ( 30 | Object.prototype.toString.call(b) === "[object Date]" && 31 | !isNaN(b.getTime()) 32 | ) { 33 | bType = "date"; 34 | } 35 | } 36 | if (aType === "object") { 37 | if (Array.isArray(a)) { 38 | aType = "array"; 39 | if (bType === "array") { 40 | let aSmaller = false; 41 | let minLen, maxLen; 42 | if (a.length < b.length) { 43 | aSmaller = true; 44 | minLen = a.length; 45 | maxLen = b.length; 46 | } else { 47 | minLen = b.length; 48 | maxLen = a.length; 49 | } 50 | for (let key = 0; key < minLen; key++) { 51 | getDiff(a[key], b[key]).forEach((change) => { 52 | change.path = 53 | change.path !== "" ? key + "." + change.path : key.toString(); 54 | changes.push(change); 55 | }); 56 | } 57 | if (aSmaller) { 58 | for (let key = minLen; key < maxLen; key++) { 59 | changes.push({ 60 | path: key.toString(), 61 | operation: "add", 62 | value: b[key], 63 | }); 64 | } 65 | } else { 66 | for (let key = minLen; key < maxLen; key++) { 67 | changes.push({ path: key.toString(), operation: "delete" }); 68 | } 69 | } 70 | return changes; 71 | } else { 72 | return [{ path: "", operation: "update", value: b }]; 73 | } 74 | } else if ( 75 | Object.prototype.toString.call(a) === "[object Date]" && 76 | !isNaN(a.getTime()) 77 | ) { 78 | aType = "date"; 79 | if (bType === "date" && b.toISOString() === a.toISOString()) { 80 | return []; 81 | } 82 | return [{ path: "", operation: "update", value: b }]; 83 | } else if (a !== null && bType === "object" && b !== null) { 84 | const keys = new Set([...Object.keys(a), ...Object.keys(b)]); 85 | for (const key of keys) { 86 | if (key in a) { 87 | if (key in b) { 88 | getDiff(a[key], b[key]).forEach((change) => { 89 | change.path = change.path !== "" ? key + "." + change.path : key; 90 | changes.push(change); 91 | }); 92 | } else { 93 | changes.push({ path: key, operation: "delete" }); 94 | } 95 | } else if (key in b) { 96 | changes.push({ path: key, operation: "add", value: b[key] }); 97 | } 98 | } 99 | return changes; 100 | } else { 101 | return a === b ? [] : [{ path: "", operation: "update", value: b }]; 102 | } 103 | } else { 104 | return a === b ? [] : [{ path: "", operation: "update", value: b }]; 105 | } 106 | // console.error(`This should not be happening...`); 107 | } 108 | -------------------------------------------------------------------------------- /src/helpers/first-char-uppercase.ts: -------------------------------------------------------------------------------- 1 | export function firstCharUppercase(str: string) { 2 | return str[0].toUpperCase() + str.substr(1); 3 | } 4 | -------------------------------------------------------------------------------- /src/helpers/get-full-myshopify-domain.ts: -------------------------------------------------------------------------------- 1 | import { getSubdomain } from "./get-subdomain"; 2 | 3 | /** 4 | * bestshopever -> bestshopever.myshopify.com 5 | * @param domain 6 | */ 7 | export function getFullMyshopifyDomain(domain: string) { 8 | const subdomain = getSubdomain(domain); 9 | return subdomain + ".myshopify.com"; 10 | } 11 | -------------------------------------------------------------------------------- /src/helpers/get-subdomain.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * bestshopever.myshopify.com -> bestshopever 3 | * @param domain 4 | */ 5 | export function getSubdomain(domain: string) { 6 | const index = domain.indexOf("."); 7 | if (index >= 0) { 8 | return domain.substring(0, index); 9 | } 10 | return domain; 11 | } 12 | -------------------------------------------------------------------------------- /src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./chunk-array"; 2 | export * from "./delete-undefined-properties"; 3 | export * from "./diff"; 4 | export * from "./first-char-uppercase"; 5 | export * from "./get-full-myshopify-domain"; 6 | export * from "./get-subdomain"; 7 | export * from "./retry-helpers"; 8 | export * from "./underscore-case"; 9 | -------------------------------------------------------------------------------- /src/helpers/retry-helpers.ts: -------------------------------------------------------------------------------- 1 | import * as pRetry from "p-retry"; 2 | import { Infrastructure } from "shopify-admin-api"; 3 | import { FetchError } from "node-fetch"; 4 | 5 | import { Error as MongooseError } from "mongoose"; 6 | 7 | /** 8 | * wrap pRetry to handle Shopify API requests 9 | * options are the same as for pRetry, but an array parameter `retryHttpCodes` is added. 10 | * This will only retry requests which throw a Shopify error with one of the specified codes 11 | * OR that throw an EAI_AGAIN network error (stemming from `fetch`). 12 | * 13 | * If you want to pass 14 | * 15 | * @param promiseFn 16 | * @param retryHttpCodes 17 | * @param options 18 | */ 19 | export function shopifyRetry( 20 | promiseFn: (attempt?: number) => Promise, 21 | retryHttpCodes: number[] = [429], 22 | // default options are filled in by retry module 23 | // @see https://github.com/tim-kos/node-retry#retryoperationoptions 24 | options: pRetry.Options = {} 25 | ) { 26 | return pRetry(async (n?: number) => { 27 | return promiseFn(n).catch((e: Error) => { 28 | if (e instanceof Infrastructure.ShopifyError) { 29 | if (retryHttpCodes.indexOf(e.statusCode) === -1) { 30 | // this will abort the pRetry chain and make pRetry reject with the original error. 31 | throw new pRetry.AbortError(e); 32 | } 33 | } else if (e instanceof FetchError) { 34 | if ((e as any).code !== "EAI_AGAIN") { 35 | throw new pRetry.AbortError(e); 36 | } 37 | } 38 | // rethrow the error as it is: this will not abort the pRetry chain 39 | throw e; 40 | }); 41 | }, options); 42 | } 43 | 44 | /** 45 | * same usage as pRetry 46 | * will retry only if error is a Mongoose ParalelSaveError 47 | * 48 | * used in cases where an object might be saved multiple times in parallel, but 49 | * with known guaranteed compatible updates. 50 | * 51 | * @param promiseFn 52 | * @param options 53 | */ 54 | export function mongooseParallelRetry( 55 | promiseFn: (attempt?: number) => Promise, 56 | options: pRetry.Options = {} 57 | ) { 58 | return pRetry(async (n?: number) => { 59 | return promiseFn(n).catch((e: Error) => { 60 | if ( 61 | !( 62 | e instanceof MongooseError.ParallelSaveError || 63 | e.name === "ParallelSaveError" 64 | ) 65 | ) { 66 | // this will abort the pRetry chain and make pRetry reject with the original error. 67 | throw new pRetry.AbortError(e); 68 | } 69 | // rethrow the error as it is: this will not abort the pRetry chain 70 | throw e; 71 | }); 72 | }, options); 73 | } 74 | -------------------------------------------------------------------------------- /src/helpers/underscore-case.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * E.g. converts `smartCollections` to `smart_collections` 3 | */ 4 | export function underscoreCase(str: string) { 5 | return str 6 | .split(/(?=[A-Z])/) 7 | .join("_") 8 | .toLowerCase(); 9 | } 10 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "shopify-admin-api"; 2 | 3 | export * from "./shopify.module"; 4 | 5 | export { SHOPIFY_MODULE_OPTIONS } from "./shopify.constants"; 6 | 7 | export * from "./interfaces"; 8 | export * from "./shop/interfaces"; 9 | export * from "./auth/interfaces"; 10 | export * from "./api/interfaces"; 11 | export * from "./charge/interfaces"; 12 | 13 | export * from "./middlewares"; 14 | export * from "./guards"; 15 | export * from "./socket"; 16 | 17 | export { ShopifyShopSchema } from "./shop/shop.schema"; 18 | 19 | export { DebugService } from "./debug.service"; 20 | -------------------------------------------------------------------------------- /src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./mongoose/sync-progress.schema"; 2 | export * from "./resource"; 3 | export * from "./session"; 4 | export * from "./session-socket"; 5 | export * from "./shopify-module-options"; 6 | export * from "./sync-options"; 7 | export * from "./sync-progress"; 8 | export * from "./sub-sync-progress"; 9 | export * from "./sub-sync-progress-finished-callback"; 10 | export * from "./user-request"; 11 | export * from "./webhook"; 12 | -------------------------------------------------------------------------------- /src/interfaces/mongoose/sync-progress.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Document } from "mongoose"; 2 | import type { ISyncProgress } from "../sync-progress"; 3 | import type { 4 | ISubSyncProgress, 5 | IOrderSyncProgress, 6 | IBlogSyncProgress, 7 | } from "../sub-sync-progress"; 8 | 9 | export const SyncOptionsSchema = new Schema({ 10 | includeOrders: Boolean, 11 | includeTransactions: Boolean, 12 | includeBlogs: Boolean, 13 | includeArticles: Boolean, 14 | includeProducts: Boolean, 15 | includePages: Boolean, 16 | includeSmartCollections: Boolean, 17 | includeCustomCollections: Boolean, 18 | resync: Boolean, 19 | cancelExisting: Boolean, 20 | }); 21 | 22 | export const SubSyncProgressSchema = new Schema( 23 | { 24 | info: String, 25 | shop: String, 26 | shopifyCount: Number, 27 | syncedCount: Number, 28 | sinceId: Number, 29 | lastId: Number, 30 | createdAt: Date, 31 | updatedAt: Date, 32 | state: String, 33 | error: String, 34 | 35 | continuedFromPrevious: { 36 | type: Schema.Types.ObjectId, 37 | ref: "shopify_sync-progress", 38 | }, 39 | }, 40 | { 41 | timestamps: true, 42 | } 43 | ); 44 | 45 | export type SubSyncProgressDocument = ISubSyncProgress & Document; 46 | 47 | export const OrderSyncProgressSchema = new Schema( 48 | { 49 | info: String, 50 | shop: String, 51 | shopifyCount: Number, 52 | syncedCount: Number, 53 | syncedTransactionsCount: Number, 54 | sinceId: Number, 55 | lastId: Number, 56 | 57 | includeTransactions: Boolean, 58 | 59 | createdAt: Date, 60 | updatedAt: Date, 61 | state: String, 62 | error: String, 63 | 64 | continuedFromPrevious: { 65 | type: Schema.Types.ObjectId, 66 | ref: "shopify_sync-progress", 67 | }, 68 | }, 69 | { 70 | timestamps: true, 71 | } 72 | ); 73 | 74 | export type OrderSyncProgressDocument = IOrderSyncProgress & Document; 75 | 76 | export type BlogSyncProgressDocument = IBlogSyncProgress & Document; 77 | 78 | export const SyncProgressSchema = new Schema( 79 | { 80 | shop: String, 81 | options: SyncOptionsSchema, 82 | orders: OrderSyncProgressSchema, 83 | products: SubSyncProgressSchema, 84 | pages: SubSyncProgressSchema, 85 | smartCollections: SubSyncProgressSchema, 86 | customCollections: SubSyncProgressSchema, 87 | createdAt: Date, 88 | updatedAt: Date, 89 | state: String, 90 | lastError: String, 91 | }, 92 | { 93 | timestamps: true, 94 | } 95 | ); 96 | 97 | export type SyncProgressDocument = ISyncProgress & 98 | Document & { options: Document; orders: Document; products: Document }; 99 | -------------------------------------------------------------------------------- /src/interfaces/resource.ts: -------------------------------------------------------------------------------- 1 | export type Resource = 2 | | "accessScopes" 3 | | "blogs" 4 | | "checkouts" 5 | | "orders" 6 | | "products" 7 | | "productVariants" 8 | | "customers" 9 | | "transactions" 10 | | "themes" 11 | | "assets" 12 | | "pages" 13 | | "articles" 14 | | "customCollections" 15 | | "smartCollections" 16 | | "collects" 17 | | "themes"; 18 | export type ResourceSignular = 19 | | "accessScope" 20 | | "blog" 21 | | "checkout" 22 | | "order" 23 | | "product" 24 | | "product_variant" 25 | | "customer" 26 | | "transaction" 27 | | "theme" 28 | | "asset" 29 | | "page" 30 | | "article" 31 | | "custom_collection" 32 | | "smart_collection" 33 | | "collect" 34 | | "theme"; 35 | -------------------------------------------------------------------------------- /src/interfaces/session-socket.ts: -------------------------------------------------------------------------------- 1 | import type { Socket } from "socket.io"; 2 | import type { SessionData, Session as ExpressSession } from "express-session"; 3 | import type { Session } from "./session"; 4 | 5 | export interface SessionHandshake { 6 | session?: Session & ExpressSession & Partial; 7 | headers: any; 8 | time: string; 9 | address: string; 10 | xdomain: boolean; 11 | secure: boolean; 12 | issued: number; 13 | url: string; 14 | query: any; 15 | auth: any; 16 | } 17 | 18 | export interface SessionSocket extends Socket { 19 | handshake: SessionHandshake; 20 | id: string; 21 | } 22 | -------------------------------------------------------------------------------- /src/interfaces/session.ts: -------------------------------------------------------------------------------- 1 | import type { IShopifyConnect } from "../auth/interfaces/connect"; 2 | import type { Session as ExpressSession } from "express-session"; 3 | 4 | export interface Session extends ExpressSession { 5 | /** @deprecated use session[`user-${user.myshopify_domain}`] instead */ 6 | user?: IShopifyConnect; 7 | /** 8 | * This is used to get access to any shopify api, also if the user is not logged in. 9 | * So DO NOT use this to check if the user is logged in and do not make this public! 10 | * @see get-shopify-connect.middleware.ts 11 | * @deprecated Use req.session[`shopify-connect-${shop}`] instead 12 | **/ 13 | shopifyConnect?: IShopifyConnect; 14 | // see get-request-type.middleware.ts 15 | isAppBackendRequest?: boolean; 16 | isThemeClientRequest?: boolean; 17 | isUnknownClientRequest?: boolean; 18 | isLoggedInToAppBackend?: boolean; 19 | /** @deprecated use req.shop instead */ 20 | shop?: string; 21 | /** if the user is logged in in multiple shops then all these shops are listed here*/ 22 | shops?: string[]; 23 | /** The latest shop domain, if the user is logged in in multiple shops this is the shop domain with which the last request was made */ 24 | currentShop?: string; 25 | // See auth.controller.ts 26 | nonce?: string; 27 | // passport 28 | passport: { 29 | user: number; 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/interfaces/shopify-module-options.ts: -------------------------------------------------------------------------------- 1 | import type { IPlan } from "../charge/interfaces/plan"; 2 | import type { Enums } from "shopify-admin-api"; 3 | import type { Resource } from "./resource"; 4 | 5 | export interface ConfigApp { 6 | root: string; 7 | protocol: "https" | "http"; 8 | host: string; 9 | port: number; 10 | debug: boolean; 11 | test: boolean; 12 | environment: "production" | "development" | "test"; 13 | } 14 | 15 | export interface ConfigSync { 16 | enabled: boolean; 17 | /** 18 | * Resources wich should be auto synced to the app's database, e.g. ['orders', 'blogs'] 19 | */ 20 | autoSyncResources: Resource[]; 21 | } 22 | 23 | export interface ConfigShopify { 24 | /** client id / Api key */ 25 | clientID: string; 26 | /** shared secret / client Secret / API secret key */ 27 | clientSecret: string; 28 | /** callback url / redirect url */ 29 | callbackURL: string; 30 | /** callback url used in shopify iframe */ 31 | iframeCallbackURL: string; 32 | // successRedirectURL?: string; 33 | // failureRedirectURL?: string; 34 | scope: string[]; 35 | webhooks: { 36 | autoSubscribe: Enums.WebhookTopic[]; 37 | }; 38 | } 39 | 40 | export interface ConfigCharges { 41 | plans: IPlan[]; 42 | frontend_return_url: string; 43 | } 44 | 45 | export interface ConfigCache { 46 | store: "memory"; 47 | ttl: number; 48 | max: number; 49 | [Key: string]: any; 50 | } 51 | 52 | export interface ConfigMongoDB { 53 | host?: string; 54 | port?: number; 55 | username?: string; 56 | password?: string; 57 | database?: string; 58 | /** Optional MongoDB connection string */ 59 | url?: string; 60 | } 61 | export interface ShopifyModuleOptions { 62 | app: ConfigApp; 63 | 64 | sync: ConfigSync; 65 | 66 | shopify: ConfigShopify; 67 | 68 | charges: ConfigCharges; 69 | 70 | /** 71 | * Cache manager options 72 | * @see https://github.com/BryanDonovan/node-cache-manager 73 | */ 74 | cache: ConfigCache; 75 | 76 | mongodb: ConfigMongoDB; 77 | } 78 | -------------------------------------------------------------------------------- /src/interfaces/sub-sync-progress-finished-callback.ts: -------------------------------------------------------------------------------- 1 | import type { SubSyncProgressDocument } from "./mongoose/sync-progress.schema"; 2 | export type ISubSyncProgressFinishedCallback = ( 3 | doc: SubSyncProgressDocument 4 | ) => void; 5 | -------------------------------------------------------------------------------- /src/interfaces/sub-sync-progress.ts: -------------------------------------------------------------------------------- 1 | import type { Types } from "mongoose"; 2 | 3 | export interface ISubSyncProgress { 4 | /** 5 | * An info text to show on sync progress in fronted 6 | */ 7 | info: string; 8 | shop: string; 9 | shopifyCount: number; 10 | syncedCount: number; 11 | sinceId: number; 12 | lastId: number; 13 | createdAt: Date; 14 | updatedAt: Date; 15 | error: string | null; 16 | state: 17 | | "starting" 18 | | "running" 19 | | "failed" 20 | | "cancelling" 21 | | "cancelled" 22 | | "success"; 23 | continuedFromPrevious?: Types.ObjectId; 24 | } 25 | 26 | export interface IOrderSyncProgress extends ISubSyncProgress { 27 | includeTransactions: boolean; 28 | syncedTransactionsCount: number; 29 | } 30 | 31 | export interface IBlogSyncProgress extends ISubSyncProgress { 32 | includeArticles: boolean; 33 | syncedArticlesCount: number; 34 | } 35 | -------------------------------------------------------------------------------- /src/interfaces/sync-options.ts: -------------------------------------------------------------------------------- 1 | export interface IStartSyncOptions { 2 | /** 3 | * If true, sync the receive data to the internal database (MongoDB) 4 | */ 5 | syncToDb: boolean; 6 | includeOrders: boolean; 7 | includeTransactions: boolean; 8 | includeProducts: boolean; 9 | includePages: boolean; 10 | includeCustomCollections: boolean; 11 | includeSmartCollections: boolean; 12 | resync: boolean; 13 | cancelExisting: boolean; 14 | } 15 | -------------------------------------------------------------------------------- /src/interfaces/sync-progress.ts: -------------------------------------------------------------------------------- 1 | import type { IStartSyncOptions } from "./sync-options"; 2 | import type { ISubSyncProgress, IOrderSyncProgress } from "./sub-sync-progress"; 3 | import type { Types } from "mongoose"; 4 | 5 | export interface ISyncProgress { 6 | _id: Types.ObjectId; 7 | shop: string; 8 | options: IStartSyncOptions; 9 | orders?: IOrderSyncProgress; 10 | products?: ISubSyncProgress; 11 | createdAt: Date; 12 | updatedAt: Date; 13 | state: "running" | "failed" | "cancelled" | "success" | "starting"; 14 | lastError: string | null; 15 | } 16 | -------------------------------------------------------------------------------- /src/interfaces/user-request.ts: -------------------------------------------------------------------------------- 1 | import type { Request } from "express"; 2 | import type { IShopifyConnect } from "../auth/interfaces/connect"; 3 | import type { Session } from "./session"; 4 | 5 | interface IUserRequest extends Request { 6 | /** Logged in user, setted by passport. This is used to check if the user is logged in */ 7 | user?: IShopifyConnect; 8 | shop?: string; 9 | /** @deprecated use req.session.shops instead */ 10 | shops?: string[]; 11 | /** @deprecated Use req.session[`shopify-connect-${shop}`] instead */ 12 | shopifyConnect?: IShopifyConnect; 13 | session: Session & Request["session"]; 14 | } 15 | 16 | export { IShopifyConnect, IUserRequest }; 17 | -------------------------------------------------------------------------------- /src/interfaces/webhook.ts: -------------------------------------------------------------------------------- 1 | import type { Error } from "mongoose"; 2 | 3 | export interface WebhookError extends Error { 4 | body: any; 5 | apiRateLimitReached: boolean; 6 | errors: { 7 | address?: string[]; 8 | topic?: string[]; 9 | }; 10 | statusCode: number; 11 | statusText: string; 12 | message: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArtCodeStudio/nestjs-shopify-module/6f8e03ceeb96bb5e95635b8f2d849922ecf95ef1/src/main.ts -------------------------------------------------------------------------------- /src/middlewares/body-parser-json.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from "@nestjs/common"; 2 | import * as bodyParser from "body-parser"; 3 | import { IUserRequest } from "../interfaces/user-request"; 4 | import { NextFunction } from "express"; 5 | import { ServerResponse } from "http"; 6 | 7 | /** 8 | * Body parser json middleware to use the middleware if you have disabled the default nest paser middleware 9 | * @see https://github.com/nestjs/nest/blob/master/packages/core/nest-application.ts#L159 10 | * @see https://github.com/expressjs/body-parser 11 | */ 12 | @Injectable() 13 | export class BodyParserJsonMiddleware implements NestMiddleware { 14 | use(req: IUserRequest, res: ServerResponse, next: NextFunction) { 15 | const jsonParser = bodyParser.json(); 16 | return jsonParser(req, res, next); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/middlewares/body-parser-urlencoded.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from "@nestjs/common"; 2 | import * as bodyParser from "body-parser"; 3 | import { IUserRequest } from "../interfaces/user-request"; 4 | import { NextFunction } from "express"; 5 | import type { ServerResponse } from "http"; 6 | 7 | /** 8 | * Body parser urlencoded middleware to use the middleware if you have disabled the default nest paser middleware 9 | * @see https://github.com/nestjs/nest/blob/master/packages/core/nest-application.ts#L159 10 | * @see https://github.com/expressjs/body-parser 11 | */ 12 | @Injectable() 13 | export class BodyParserUrlencodedMiddleware implements NestMiddleware { 14 | use(req: IUserRequest, res: ServerResponse, next: NextFunction) { 15 | const urlencodedParser = bodyParser.urlencoded({ extended: true }); 16 | return urlencodedParser(req, res, next); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/middlewares/get-shopify-connect.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from "@nestjs/common"; 2 | import { ShopifyConnectService } from "../auth/connect.service"; 3 | import { DebugService } from "../debug.service"; 4 | import { IUserRequest } from "../interfaces/user-request"; 5 | import { Response, NextFunction } from "express"; 6 | 7 | @Injectable() 8 | export class GetShopifyConnectMiddleware implements NestMiddleware { 9 | logger = new DebugService(`shopify:${this.constructor.name}`); 10 | constructor(private readonly shopifyConnectService: ShopifyConnectService) {} 11 | async use(req: IUserRequest, res: Response, next: NextFunction) { 12 | const shop = req.shop; 13 | return this.shopifyConnectService 14 | .findByDomain(shop) 15 | .then((shopifyConnect) => { 16 | // this.logger.debug('shopifyConnect', shopifyConnect); 17 | if (!shopifyConnect) { 18 | return next(); 19 | } 20 | 21 | // set to session 22 | req.session[`shopify-connect-${shop}`] = shopifyConnect; 23 | 24 | // set to request 25 | req[`shopify-connect-${shop}`] = shopifyConnect; 26 | 27 | return next(); 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/middlewares/get-user.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from "@nestjs/common"; 2 | import { ShopifyAuthService } from "../auth/auth.service"; 3 | import { ShopifyConnectService } from "../auth/connect.service"; 4 | import { DebugService } from "../debug.service"; 5 | import { IUserRequest, IShopifyConnect } from "../interfaces/user-request"; 6 | import { Response, NextFunction } from "express"; 7 | 8 | @Injectable() 9 | export class GetUserMiddleware implements NestMiddleware { 10 | logger = new DebugService(`shopify:${this.constructor.name}`); 11 | constructor( 12 | private readonly shopifyAuthService: ShopifyAuthService, 13 | private readonly shopifyConnectService: ShopifyConnectService 14 | ) {} 15 | 16 | protected setShop(req: IUserRequest, shop: string) { 17 | req.session.shops = req.session.shops || []; 18 | if (!req.session.shops.includes(shop)) { 19 | req.session.shops.push(shop); 20 | } 21 | req.session.currentShop = shop; 22 | req.shop = shop; 23 | } 24 | 25 | async use(req: IUserRequest, res: Response, next: NextFunction) { 26 | let shop: string; 27 | 28 | const requestType = await this.shopifyAuthService 29 | .getRequestType(req) 30 | .catch((error: Error) => { 31 | if ( 32 | error && 33 | typeof error.message === "string" && 34 | error.message.toLowerCase().includes("shop not found") 35 | ) { 36 | // DO nothing 37 | this.logger.debug(error.message); 38 | } else { 39 | this.logger.error(error); 40 | } 41 | }); 42 | 43 | req.session.isLoggedInToAppBackend = false; 44 | 45 | if (requestType) { 46 | shop = requestType.myshopifyDomain; 47 | req.session.isAppBackendRequest = requestType.isAppBackendRequest; 48 | req.session.isThemeClientRequest = requestType.isThemeClientRequest; 49 | req.session.isUnknownClientRequest = requestType.isUnknownClientRequest; 50 | this.setShop(req, shop); 51 | // this.logger.debug('requestType', requestType); 52 | } 53 | 54 | // this.logger.debug('req.session', req.session); 55 | 56 | if (!shop) { 57 | shop = req.session.currentShop; 58 | this.setShop(req, shop); 59 | } 60 | 61 | /** 62 | * If shop is not set you need to add the shop to your header on your shopify app client code like this: 63 | * 64 | * ``` 65 | * JQuery.ajaxSetup({ 66 | * beforeSend: (xhr: JQueryXHR) => { 67 | * xhr.setRequestHeader('shop', shop); 68 | * }, 69 | * }); 70 | * ``` 71 | * 72 | * Or on riba with: 73 | * 74 | * ``` 75 | * Utils.setRequestHeaderEachRequest('shop', shop); 76 | * ``` 77 | */ 78 | if (!shop) { 79 | this.logger.warn("[GetUserMiddleware] Shop not found!"); 80 | return next(); 81 | } 82 | 83 | // get user from session 84 | if (req.session) { 85 | if (req.session[`user-${shop}`]) { 86 | const user = req.session[`user-${shop}`] as IShopifyConnect; 87 | // set to request (for passport and co) 88 | req.user = user; 89 | 90 | req.session.isLoggedInToAppBackend = true; 91 | return next(); 92 | } 93 | } 94 | 95 | // Get user from req 96 | if (req.user) { 97 | const user = req.user; 98 | // set to session (for websockets) 99 | this.logger.debug("\n\nSet user: ", user); 100 | req.session[`user-${shop}`] = user; 101 | req.session.isLoggedInToAppBackend = true; 102 | return next(); 103 | } 104 | 105 | // Get user from passport session 106 | if (req.session.passport && req.session.passport.user) { 107 | return this.shopifyConnectService 108 | .findByShopifyId(req.session.passport.user) 109 | .then((user) => { 110 | if (user) { 111 | // set to request (for passport and co) 112 | req.user = user; 113 | // set to session (for websockets) 114 | this.logger.debug("\n\nSet user: ", user); 115 | req.session[`user-${shop}`] = user; 116 | req.session.isLoggedInToAppBackend = true; 117 | return next(); 118 | } 119 | }) 120 | .catch((error) => { 121 | this.logger.error(error); 122 | }); 123 | } 124 | 125 | return next(); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./body-parser-json.middleware"; 2 | export * from "./body-parser-urlencoded.middleware"; 3 | export * from "./get-shopify-connect.middleware"; 4 | export * from "./get-user.middleware"; 5 | export * from "./verify-webhook.middleware"; 6 | -------------------------------------------------------------------------------- /src/middlewares/verify-webhook.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, NestMiddleware } from "@nestjs/common"; 2 | import { DebugService } from "../debug.service"; 3 | 4 | import { ShopifyModuleOptions } from "../interfaces/shopify-module-options"; 5 | import { SHOPIFY_MODULE_OPTIONS } from "../shopify.constants"; 6 | import { Auth } from "shopify-admin-api"; 7 | import concat = require("concat-stream"); 8 | import { IUserRequest } from "../interfaces/user-request"; 9 | import { Response, NextFunction } from "express"; 10 | 11 | @Injectable() 12 | export class VerifyWebhookMiddleware implements NestMiddleware { 13 | logger = new DebugService(`shopify:${this.constructor.name}`); 14 | constructor( 15 | @Inject(SHOPIFY_MODULE_OPTIONS) 16 | private readonly shopifyModuleOptions: ShopifyModuleOptions 17 | ) {} 18 | async use(req: IUserRequest, res: Response, next: NextFunction) { 19 | this.logger.debug("verifyWebhook middleware"); 20 | this.logger.debug("req.headers", req.headers); 21 | const hmac = req.headers["x-shopify-hmac-sha256"]; 22 | let rawBody: any; 23 | 24 | this.logger.debug("verifyWebhook middleware hmac", hmac); 25 | req.pipe( 26 | concat((data) => { 27 | rawBody = data; 28 | 29 | // this.logger.debug(`webhook rawBody:`, rawBody); 30 | try { 31 | req.body = JSON.parse(rawBody); 32 | // this.logger.debug(`webhook parsed body:`, rawBody); 33 | } catch (e) { 34 | req.body = {}; 35 | this.logger.error(`webhook failed parsing body: ${rawBody}`); 36 | return res 37 | .status(e.statusCode || 415) 38 | .send({ error: "INVALID JSON" }); 39 | } 40 | if (hmac) { 41 | if ( 42 | Auth.isAuthenticWebhook( 43 | req.headers, 44 | rawBody, 45 | this.shopifyModuleOptions.shopify.clientSecret 46 | ) 47 | ) { 48 | return next(); 49 | } else { 50 | this.logger.error(`invalid webhook hmac: ${hmac}`); 51 | return res.status(403).send({ error: "INVALID HMAC" }); 52 | // TODO: How to throw error? 53 | // return ctx.throw(401, 'SHOPIFY_POLICIES_WEBHOOK_INVALID_HMAC'); 54 | } 55 | } 56 | }) 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/shop/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./shop"; 2 | -------------------------------------------------------------------------------- /src/shop/shop.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ShopController } from './shop.controller'; 3 | 4 | import { ShopifyModule } from '../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('Shop Controller', () => { 9 | let module: TestingModule; 10 | beforeAll(async () => { 11 | module = await Test.createTestingModule({ 12 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 13 | }).compile(); 14 | }); 15 | it('should be defined', () => { 16 | const controller: ShopController = module.get(ShopController); 17 | expect(controller).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/shop/shop.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | HttpStatus, 5 | HttpException, 6 | Param, 7 | } from "@nestjs/common"; 8 | 9 | import { Roles } from "../guards/roles.decorator"; // '../../app.module'; 10 | 11 | import { ShopService } from "./shop.service"; 12 | 13 | import { DebugService } from "../debug.service"; 14 | 15 | @Controller("shopify/shop") 16 | export class ShopController { 17 | protected logger = new DebugService("shopify:ShopController"); 18 | 19 | constructor(private readonly shopService: ShopService) {} 20 | 21 | /** 22 | * Get a list of all connected shopify accounts 23 | * @param req 24 | */ 25 | @Get() 26 | @Roles("admin") 27 | connects() { 28 | return this.shopService.findAll().catch((error: Error) => { 29 | this.logger.error(error); 30 | throw new HttpException( 31 | `Failure on get shops`, 32 | HttpStatus.INTERNAL_SERVER_ERROR 33 | ); 34 | }); 35 | } 36 | 37 | /** 38 | * Get a connected instagram account by shopify store id 39 | * @param id 40 | */ 41 | @Get("/:id") 42 | @Roles("admin") 43 | connect(@Param("id") id) { 44 | return this.shopService 45 | .findByShopifyID(Number(id)) 46 | .catch((error: Error) => { 47 | this.logger.error(error); 48 | throw new HttpException( 49 | { 50 | message: `Failure on get shop with id ${id}.`, 51 | id, 52 | }, 53 | HttpStatus.INTERNAL_SERVER_ERROR 54 | ); 55 | }); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/shop/shop.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { ShopService } from './shop.service'; 3 | 4 | import { ShopifyModule } from '../shopify.module'; 5 | import { config, mongooseConnectionPromise } from '../../test/config.test'; 6 | import * as passport from 'passport'; 7 | 8 | describe('ShopService', () => { 9 | let service: ShopService; 10 | beforeAll(async () => { 11 | const module = await Test.createTestingModule({ 12 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 13 | }).compile(); 14 | service = module.get(ShopService); 15 | }); 16 | it('should be defined', () => { 17 | expect(service).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/shop/shop.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Inject } from "@nestjs/common"; 2 | import { Model } from "mongoose"; 3 | 4 | import { IShopifyConnectDocument } from "../auth/interfaces/connect"; 5 | import { IShopifyShop } from "./interfaces/shop"; 6 | import { DebugService } from "../debug.service"; 7 | 8 | @Injectable() 9 | export class ShopService { 10 | protected logger = new DebugService("shopify:ShopifyConnectService"); 11 | 12 | constructor( 13 | @Inject("ShopifyConnectModelToken") 14 | private readonly shopifyConnectModel: Model 15 | ) {} 16 | 17 | async findAll(): Promise { 18 | return this.shopifyConnectModel 19 | .find() 20 | .exec() 21 | .then((connects: IShopifyConnectDocument[]) => { 22 | const shops: IShopifyShop[] = []; 23 | connects.forEach((connect) => { 24 | shops.push(connect.shop); 25 | }); 26 | return shops; 27 | }); 28 | } 29 | 30 | async findByShopifyID(id: number, fields?: string[]): Promise { 31 | return this.shopifyConnectModel 32 | .findOne({ shopifyID: id }) 33 | .exec() 34 | .then((connect: IShopifyConnectDocument) => { 35 | let shop; 36 | if (fields) { 37 | shop = {}; 38 | fields.forEach((property) => { 39 | if (shop.hasOwnProperty(property)) { 40 | shop[property] = connect.shop[property]; 41 | } 42 | }); 43 | } else { 44 | shop = connect.shop; 45 | } 46 | return shop; 47 | }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/shopify.constants.ts: -------------------------------------------------------------------------------- 1 | export const SHOPIFY_MODULE_OPTIONS = "ShopifyModuleOptions"; 2 | -------------------------------------------------------------------------------- /src/socket/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./session-io.adapter"; 2 | -------------------------------------------------------------------------------- /src/socket/session-io.adapter.ts: -------------------------------------------------------------------------------- 1 | import { INestApplicationContext } from "@nestjs/common"; 2 | import { IoAdapter } from "@nestjs/platform-socket.io"; 3 | import { Socket } from "socket.io"; 4 | import { NextFunction, RequestHandler } from "express"; 5 | import * as sharedsession from "express-socket.io-session"; 6 | import { DebugService } from "../debug.service"; 7 | 8 | // TODO: Using this until socket.io v3 is part of Nest.js, see: https://github.com/nestjs/nest/issues/5676 9 | export class SessionIoAdapter extends IoAdapter { 10 | protected logger = new DebugService(`shopify:${this.constructor.name}`); 11 | protected socketSessionMiddleware: ( 12 | socket: Socket, 13 | next: NextFunction 14 | ) => void; 15 | constructor( 16 | session: RequestHandler, 17 | appOrHttpServer?: INestApplicationContext | any 18 | ) { 19 | super(appOrHttpServer); 20 | /** 21 | * Make session available on socket.io client socket object 22 | * @see https://github.com/oskosk/express-socket.io-session 23 | */ 24 | this.socketSessionMiddleware = sharedsession(session, { 25 | autoSave: true, 26 | }) as any; // TODO socket.io version conflict 27 | } 28 | 29 | public createIOServer(port: number, options?: any): any { 30 | const server = super.createIOServer(port, options); 31 | server.use(this.socketSessionMiddleware); 32 | return server; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/sync/sync-providers.ts: -------------------------------------------------------------------------------- 1 | import { Model, Mongoose } from "mongoose"; 2 | import { SyncProgressSchema, SyncProgressDocument } from "../interfaces"; 3 | 4 | import { EventService } from "../event.service"; 5 | 6 | import { DebugService } from "../debug.service"; 7 | 8 | const logger = new DebugService("shopify:sync-providers"); 9 | 10 | /** 11 | * 12 | * @param connection 13 | * 14 | * @event sync:[shop]:[progress_.id] (progress: SyncProgressDocument) 15 | * @event sync (shop: string, progress: SyncProgressDocument) 16 | * @event sync-ended:[shop]:[progress._id] (progress: SyncProgressDocument) 17 | * @event sync-ended (shop: string, progress: SyncProgressDocument) 18 | * @event sync-success (shop: string, progress: SyncProgressDocument) 19 | * @event sync-failed (shop: string, progress: SyncProgressDocument) 20 | * @event sync-cancelled (shop: string, progress: SyncProgressDocument) 21 | */ 22 | const syncProviders = (connection: Mongoose) => { 23 | return [ 24 | { 25 | inject: [EventService], 26 | provide: "SyncProgressModelToken", 27 | useFactory: (eventService: EventService) => { 28 | SyncProgressSchema.post( 29 | "save", 30 | (progress: SyncProgressDocument, next) => { 31 | logger.debug( 32 | `SyncProgress Post save hook:`, 33 | progress.id, 34 | progress._id, 35 | progress.state 36 | ); 37 | // logger.debug(`emit sync:${progress.shop}:${progress._id}`, progress); 38 | eventService.emit( 39 | `sync:${progress.shop}:${progress._id}`, 40 | progress 41 | ); 42 | // logger.debug(`emit sync`, progress.shop, progress); 43 | eventService.emit(`sync`, progress.shop, progress); 44 | if (progress.state !== "running") { 45 | // logger.debug(`emit sync-ended:${progress.shop}:${progress._id}`, progress); 46 | eventService.emit( 47 | `sync-ended:${progress.shop}:${progress._id}`, 48 | progress 49 | ); 50 | // logger.debug(`emit sync-ended`, progress.shop, progress); 51 | eventService.emit(`sync-ended`, progress.shop, progress); 52 | switch (progress.state) { 53 | case "success": 54 | // logger.debug(`emit sync-success`, progress.shop, progress); 55 | eventService.emit(`sync-success`, progress.shop, progress); 56 | break; 57 | case "failed": 58 | // logger.debug(`emit sync-failed`, progress.shop, progress); 59 | eventService.emit(`sync-failed`, progress.shop, progress); 60 | break; 61 | case "cancelled": 62 | // logger.debug(`emit sync-cancelled`, progress.shop, progress); 63 | eventService.emit(`sync-cancelled`, progress.shop, progress); 64 | break; 65 | } 66 | } 67 | next(null); 68 | } 69 | ); 70 | return connection.model( 71 | `shopify_sync-progress`, 72 | SyncProgressSchema 73 | ) as unknown as Model; 74 | }, 75 | }, 76 | ]; 77 | }; 78 | 79 | export { syncProviders }; 80 | -------------------------------------------------------------------------------- /src/sync/sync.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SyncController } from './sync.controller'; 3 | import { ShopifyModule } from '../shopify.module'; 4 | import { config, mongooseConnectionPromise } from '../../test/config.test'; 5 | import * as passport from 'passport'; 6 | 7 | describe('Sync Controller', () => { 8 | let module: TestingModule; 9 | 10 | beforeAll(async () => { 11 | module = await Test.createTestingModule({ 12 | imports: [ShopifyModule.forRoot(config, await mongooseConnectionPromise, passport)], 13 | }).compile(); 14 | }); 15 | it('should be defined', () => { 16 | const controller: SyncController = module.get(SyncController); 17 | expect(controller).toBeDefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/sync/sync.gateway.ts: -------------------------------------------------------------------------------- 1 | import { 2 | WebSocketGateway, 3 | OnGatewayInit, 4 | OnGatewayConnection, 5 | OnGatewayDisconnect, 6 | WebSocketServer, 7 | } from "@nestjs/websockets"; 8 | import { Namespace } from "socket.io"; 9 | import { SessionSocket } from "../interfaces/session-socket"; 10 | import { SyncService } from "./sync.service"; 11 | import { DebugService } from "../debug.service"; 12 | import { EventService } from "../event.service"; 13 | import { SyncProgressDocument } from "../interfaces"; 14 | 15 | @WebSocketGateway({ namespace: "/shopify/sync/socket.io" }) 16 | export class SyncGateway 17 | implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect 18 | { 19 | @WebSocketServer() server: Namespace; 20 | 21 | protected logger = new DebugService(`shopify:${this.constructor.name}`); 22 | 23 | constructor( 24 | protected readonly eventService: EventService, 25 | protected readonly syncService: SyncService 26 | ) {} 27 | 28 | // @SubscribeMessage('start') 29 | // onAll(client: SessionSocket, options: ProductListOptions = {}): Observable> { 30 | // // return this.syncService.startSync(client.handshake.session[`shopify-connect-${session.currentShop}`], 'start', options); 31 | // } 32 | 33 | afterInit(nsp: Namespace) { 34 | this.logger.debug("afterInit", nsp.name); 35 | 36 | this.eventService.on( 37 | `sync-exception`, 38 | (myshopifyDomain: string, error: any) => { 39 | nsp.to(`${myshopifyDomain}-app-backend`).emit("sync-exception", error); 40 | } 41 | ); 42 | 43 | this.eventService.on( 44 | `sync`, 45 | (myshopifyDomain: string, progress: SyncProgressDocument) => { 46 | nsp.to(`${myshopifyDomain}-app-backend`).emit("sync", progress); 47 | } 48 | ); 49 | 50 | this.eventService.on( 51 | `sync-ended`, 52 | (myshopifyDomain: string, progress: SyncProgressDocument) => { 53 | nsp.to(`${myshopifyDomain}-app-backend`).emit("sync-ended", progress); 54 | } 55 | ); 56 | 57 | this.eventService.on( 58 | `sync-success`, 59 | (myshopifyDomain: string, progress: SyncProgressDocument) => { 60 | nsp.to(`${myshopifyDomain}-app-backend`).emit("sync-success", progress); 61 | } 62 | ); 63 | 64 | this.eventService.on( 65 | `sync-failed`, 66 | (myshopifyDomain: string, progress: SyncProgressDocument) => { 67 | nsp.to(`${myshopifyDomain}-app-backend`).emit("sync-failed", progress); 68 | } 69 | ); 70 | 71 | this.eventService.on( 72 | `sync-cancelled`, 73 | (myshopifyDomain: string, progress: SyncProgressDocument) => { 74 | nsp 75 | .to(`${myshopifyDomain}-app-backend`) 76 | .emit("sync-cancelled", progress); 77 | } 78 | ); 79 | } 80 | 81 | handleConnection(client: SessionSocket) { 82 | this.logger.debug("connect", client.id, client.handshake.session); 83 | // Join the room for app backend users to receive broadcast events 84 | if ( 85 | client.handshake.session && 86 | client.handshake.session.isAppBackendRequest && 87 | client.handshake.session.isLoggedInToAppBackend 88 | ) { 89 | client.join(`${client.handshake.session.currentShop}-app-backend`); 90 | } 91 | // Join the room for theme client visitors to receive broadcast events 92 | if ( 93 | client.handshake.session && 94 | client.handshake.session.isThemeClientRequest 95 | ) { 96 | client.join(`${client.handshake.session.currentShop}-client-theme`); 97 | } 98 | } 99 | 100 | handleDisconnect(client: SessionSocket) { 101 | this.logger.debug("disconnect", client.id); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/webhooks/webhooks.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | UseGuards, 3 | Controller, 4 | Post, 5 | Get, 6 | Req, 7 | Body, 8 | Query, 9 | Headers, 10 | Param, 11 | HttpCode, 12 | HttpStatus, 13 | HttpException, 14 | } from "@nestjs/common"; 15 | import { ShopifyApiGuard } from "../guards/shopify-api.guard"; 16 | import { Roles } from "../guards/roles.decorator"; 17 | import { IUserRequest } from "../interfaces/user-request"; 18 | import { WebhooksService } from "./webhooks.service"; 19 | import { EventService } from "../event.service"; 20 | import { DebugService } from "../debug.service"; 21 | 22 | @Controller("webhooks") 23 | export class WebhooksController { 24 | constructor( 25 | protected readonly webhooksService: WebhooksService, 26 | protected readonly eventService: EventService 27 | ) {} 28 | logger = new DebugService(`shopify:${this.constructor.name}`); 29 | 30 | @UseGuards(ShopifyApiGuard) 31 | @Roles("admin") 32 | @Get("") 33 | async listAllFromShopify(@Req() req: IUserRequest) { 34 | const webhooks = await this.webhooksService.list( 35 | req.session[`shopify-connect-${req.shop}`] 36 | ); 37 | this.logger.debug(`webhooks`, webhooks); 38 | return webhooks; 39 | } 40 | 41 | /** 42 | * Create a webhook 43 | */ 44 | @UseGuards(ShopifyApiGuard) 45 | @Roles() 46 | @Get("create") 47 | async createWebhook(@Req() req: IUserRequest, @Query("topic") topic) { 48 | try { 49 | const result = await this.webhooksService.create( 50 | req.session[`shopify-connect-${req.shop}`], 51 | topic 52 | ); 53 | this.logger.debug(`Create webhook result`, result); 54 | return result; 55 | } catch (error) { 56 | this.logger.error(error); 57 | throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); 58 | } 59 | } 60 | 61 | /** 62 | * Catch-all method for all webhooks of the form topic = :resource/:event, i.e. orders/updated 63 | */ 64 | @Post("/:resource/:event") 65 | @HttpCode(200) 66 | async catchWebhook( 67 | @Headers("X-Shopify-Shop-Domain") myShopifyDomain: string, 68 | @Headers("X-Shopify-Hmac-Sha256") hmac: string, 69 | @Headers("X-Shopify-API-Version") apiVersion: string, 70 | @Headers("X-Shopify-Topic") topic: string, 71 | @Param("resource") resource: string, 72 | @Param("event") event: string, 73 | @Body() body: any 74 | ) { 75 | try { 76 | // const topic = `${resource}/${event}`; 77 | this.logger.debug(`[${myShopifyDomain}] Webhook ${topic}`, body); 78 | this.eventService.emit(`webhook:${topic}`, myShopifyDomain, body); 79 | this.eventService.emit(`webhook:${myShopifyDomain}:${topic}`, body); 80 | } catch (error) { 81 | this.logger.error(error); 82 | throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /test/e2e/api/products/products.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import * as request from 'supertest'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { INestApplication } from '@nestjs/common'; 4 | import { ProductsController } from '../../../../src/api/products/products.controller'; 5 | import { ProductsService } from '../../../../src/api/products/products.service'; 6 | import { ShopifyConnectService } from '../../../../src/auth/connect.service'; 7 | import { IShopifyConnect } from '../../../../src/auth/interfaces'; 8 | 9 | // import { ShopifyModule } from '../../../../src/shopify.module'; 10 | // import { config, mongooseConnectionPromise } from '../../../../test/config.test'; 11 | // import * as passport from 'passport'; 12 | 13 | describe('Products Controller', () => { 14 | let app: INestApplication; 15 | let module: TestingModule; 16 | let shopifyConnectService: ShopifyConnectService; 17 | let productsService: ProductsService; 18 | let user: IShopifyConnect; 19 | 20 | beforeAll(async () => { 21 | module = await Test.createTestingModule({ 22 | controllers: [ProductsController], 23 | providers: [ShopifyConnectService, ProductsService], 24 | }).compile(); 25 | 26 | app = module.createNestApplication(); 27 | await app.init(); 28 | 29 | shopifyConnectService = module.get(ShopifyConnectService); 30 | productsService = module.get(ProductsService); 31 | 32 | user = await shopifyConnectService.findByDomain('jewelberry-dev.myshopify.com'); 33 | }); 34 | 35 | it('should be defined', () => { 36 | const controller: ProductsController = module.get(ProductsController); 37 | expect(controller).toBeDefined(); 38 | }); 39 | 40 | it('GET /shopify/api/products', () => { 41 | it('should return an array of products', async () => { 42 | 43 | return request(app.getHttpServer()) 44 | .get('/shopify/api/products') 45 | .expect(200) 46 | .expect({ 47 | data: await productsService.listFromShopify(user), 48 | }); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "noImplicitAny": false, 6 | "removeComments": true, 7 | "noLib": false, 8 | "allowSyntheticDefaultImports": true, 9 | "emitDecoratorMetadata": true, 10 | "experimentalDecorators": true, 11 | "target": "es6", 12 | "sourceMap": true, 13 | "outDir": "dist", 14 | "baseUrl": "src" 15 | }, 16 | "include": [ 17 | "src/**/*" 18 | ], 19 | "exclude": [ 20 | "node_modules", 21 | "**/*.spec.ts" 22 | ] 23 | } 24 | --------------------------------------------------------------------------------