diff --git a/.env.template b/.env.template index 8aa8775d..2d8755c1 100644 --- a/.env.template +++ b/.env.template @@ -13,4 +13,8 @@ NEXT_PUBLIC_MEASUREMENT_ID="" # Production #SITECORE_CH1_CLIENT_KEY= -#SITECORE_CH1_ENDPOINT_URL="https://edge.sitecorecloud.io/api/graphql/v1/" \ No newline at end of file +#SITECORE_CH1_ENDPOINT_URL="https://edge.sitecorecloud.io/api/graphql/v1/" + +## Azure Table Storage +AZURE_TABLE_NAME='Urls' +AZURE_TABLE_CONNECTION_STRING="UseDevelopmentStorage=true" \ No newline at end of file diff --git a/.github/workflows/component.yml b/.github/workflows/component.yml index 5bad4e2c..82a4e75d 100644 --- a/.github/workflows/component.yml +++ b/.github/workflows/component.yml @@ -23,5 +23,7 @@ jobs: SITECORE_CH1_ENDPOINT_URL: ${{ secrets.SITECORE_CH1_ENDPOINT_URL }} SITECORE_CH1_CLIENT_KEY: ${{ secrets.SITECORE_CH1_CLIENT_KEY }} NEXT_PUBLIC_EMAIL_FORM_ENDPOINT: ${{ secrets.NEXT_PUBLIC_EMAIL_FORM_ENDPOINT }} + AZURE_TABLE_NAME: 'Urls' + AZURE_TABLE_CONNECTION_STRING: ${{ secrets.AZURE_TABLE_CONNECTION_STRING }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 43a90467..c5ea8673 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -21,3 +21,5 @@ jobs: env: SITECORE_CH1_ENDPOINT_URL: ${{ secrets.SITECORE_CH1_ENDPOINT_URL }} SITECORE_CH1_CLIENT_KEY: ${{ secrets.SITECORE_CH1_CLIENT_KEY }} + AZURE_TABLE_NAME: 'Urls' + AZURE_TABLE_CONNECTION_STRING: ${{ secrets.AZURE_TABLE_CONNECTION_STRING }} diff --git a/.gitignore b/.gitignore index caec383b..9d705675 100644 --- a/.gitignore +++ b/.gitignore @@ -126,3 +126,8 @@ npm-debug.log* yarn-debug.log* yarn-error.log* .vscode/settings.json + +# Ignore azurite files +__azurite** +__blobstorage +__queuestorage \ No newline at end of file diff --git a/README.md b/README.md index 5b193951..fc4420b6 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Welcome to the open source project for the Sitecore Migration app. The purpose o - Cypress 16.6.0 - Sitecore Content Hub One (to drive the data for the application) - Sitecore CDP/Personalize (not required) +- Azure Table Storage (either using the emulator or a live account) + - Azurite for Local Development with Azure Table Storage (Version 3.29.0) ## Development @@ -21,7 +23,39 @@ To get started you need to follow the following steps: 4. Run `npm install` to install all the dependencies. 5. Run `npm run dev` to start the development server. -### Optional +## Running Azure Table Storage + +This project relies on creating outcome urls on the fly based on the users answers. To do this, we use Azure Table Storage to store the outcome urls. You can configure in your `.env` file the connection string to your Azure Table Storage account. The environment variables needed to connect to Azure Table Storage consist of: + +- AZURE_TABLE_NAME +- AZURE_TABLE_CONNECTION_STRING + +The name of the table should always be `Urls` to match up with the code. + +### Running Azure Table Storage Locally + +You can run Azure Table Storage locally, but not required. Below are the steps to run Azure Table Storage locally. + +1. Install the Azure Storage Emulator (azurite). You can follow these instructions: (https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite?tabs=npm%2Cblob-storage). Or you can install azurite globally by running `npm install -g azurite`. +2. Ensure you are able to run the command `azurite` in your terminal. If you are not able to run this command, you may need to add the path to your `azurite` executable to your PATH environment variable. +3. Update your `.env` file with the following values: + +``` +AZURE_TABLE_NAME=Urls +AZURE_TABLE_CONNECTION_STRING='UseDevelopmentStorage=true' +``` + +4. Run `npm run dev:azurite` which will start the Next.js development server and azurite local service simultaneously. + +> This will start the Azure Table Storage emulator and the development server at the same time. You can now run the application locally. + +5. If this is the first time running the above command, you will need to create the table in Azure Table Storage manually (after you've run step 4). You can do this by connecting to the local emulator using Azure Storage Explorer. You can download Azure Storage Explorer here: https://azure.microsoft.com/en-us/features/storage-explorer/ +6. Once you have downloaded Azure Storage Explorer, install it, and then open the tool. +7. Along the left side of the tool is the "Explorer", you should see an option for "Emulator & Attached" which you should expand. +8. You should then see an option for "Emulator - Default Ports" which you should expand. +9. You should then see an option for "Tables", which you should right click on and select "Create Table". +10. Enter the name of the table as `Urls` and click "Create". +11. You should now see the table in the list of tables and now be able to run the application locally. Navigate to your website in the browser (ie. http://localhost:3000). ### Running Cypress Tests diff --git a/package-lock.json b/package-lock.json index 17c57433..af179398 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@apollo/client": "^3.7.14", + "@azure/data-tables": "^13.2.2", "@chakra-ui/icons": "^2.0.19", "@chakra-ui/next-js": "^2.1.4", "@chakra-ui/react": "^2.7.1", @@ -61,6 +62,7 @@ "react-icons": "4.8.0", "react-lite-youtube-embed": "^2.3.52", "typescript": "^4.9.5", + "uuid": "^9.0.1", "web-vitals": "^2.1.4" }, "devDependencies": { @@ -68,6 +70,7 @@ "@types/node": "^16.18.16", "@types/react": "^18.0.28", "@types/react-dom": "^18.0.11", + "@types/uuid": "^9.0.7", "concurrently": "^8.2.0", "eslint": "^8.41.0" } @@ -131,6 +134,149 @@ } } }, + "node_modules/@azure/abort-controller": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz", + "integrity": "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw==", + "dependencies": { + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.5.0.tgz", + "integrity": "sha512-udzoBuYG1VBoHVohDTrvKjyzel34zt77Bhp7dQntVGGD0ehVq48owENbBG8fIgkHRNUBQH5k1r0hpoMu5L8+kw==", + "dependencies": { + "@azure/abort-controller": "^1.0.0", + "@azure/core-util": "^1.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.7.3.tgz", + "integrity": "sha512-kleJ1iUTxcO32Y06dH9Pfi9K4U+Tlb111WXEnbt7R/ne+NLRwppZiTGJuTD5VVoxTMK5NTbEtm5t2vcdNCFe2g==", + "dependencies": { + "@azure/abort-controller": "^1.0.0", + "@azure/core-auth": "^1.4.0", + "@azure/core-rest-pipeline": "^1.9.1", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.0.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@azure/core-paging": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.5.0.tgz", + "integrity": "sha512-zqWdVIt+2Z+3wqxEOGzR5hXFZ8MGKK52x4vFLw8n58pR6ZfKRx3EXYTxTaYxYHc/PexPUTyimcTWFJbji9Z6Iw==", + "dependencies": { + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.13.0.tgz", + "integrity": "sha512-a62aP/wppgmnfIkJLfcB4ssPBcH94WzrzPVJ3tlJt050zX4lfmtnvy95D3igDo3f31StO+9BgPrzvkj4aOxnoA==", + "dependencies": { + "@azure/abort-controller": "^1.1.0", + "@azure/core-auth": "^1.4.0", + "@azure/core-tracing": "^1.0.1", + "@azure/core-util": "^1.3.0", + "@azure/logger": "^1.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.0.1.tgz", + "integrity": "sha512-I5CGMoLtX+pI17ZdiFJZgxMJApsK6jjfm85hpgp3oazCdq5Wxgh4wMr7ge/TTWW1B5WBuvIOI1fMU/FrOAMKrw==", + "dependencies": { + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.6.1.tgz", + "integrity": "sha512-h5taHeySlsV9qxuK64KZxy4iln1BtMYlNt5jbuEFN3UFSAd1EwKg/Gjl5a6tZ/W8t6li3xPnutOx7zbDyXnPmQ==", + "dependencies": { + "@azure/abort-controller": "^1.0.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@azure/core-xml": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.3.4.tgz", + "integrity": "sha512-B1xI79Ur/u+KR69fGTcsMNj8KDjBSqAy0Ys6Byy4Qm1CqoUy7gCT5A7Pej0EBWRskuH6bpCwrAnosfmQEalkcg==", + "dependencies": { + "fast-xml-parser": "^4.2.4", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@azure/data-tables": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/@azure/data-tables/-/data-tables-13.2.2.tgz", + "integrity": "sha512-Dq2Aq0mMMF0BPzYQKdBY/OtO7VemP/foh6z+mJpUO1hRL+65C1rGQUJf20LJHotSyU8wHb4HJzOs+Z50GXSy1w==", + "dependencies": { + "@azure/core-auth": "^1.3.0", + "@azure/core-client": "^1.0.0", + "@azure/core-paging": "^1.1.1", + "@azure/core-rest-pipeline": "^1.1.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-xml": "^1.0.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.2.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@azure/data-tables/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@azure/logger": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.0.4.tgz", + "integrity": "sha512-ustrPY8MryhloQj7OWGe+HrYx+aoiOxzbXTtgblbV3xwCqpzUK36phH3XNHQKj3EPonyFUuDTfR3qFhTEAuZEg==", + "dependencies": { + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.23.5", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", @@ -1739,6 +1885,14 @@ "node": ">= 6" } }, + "node_modules/@cypress/request/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@cypress/xvfb": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", @@ -2985,6 +3139,14 @@ "@tiptap/core": "^2.0.0" } }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "engines": { + "node": ">= 10" + } + }, "node_modules/@types/aria-query": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.1.tgz", @@ -3104,6 +3266,12 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz", "integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==" }, + "node_modules/@types/uuid": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.7.tgz", + "integrity": "sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==", + "dev": true + }, "node_modules/@types/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.0.tgz", @@ -3182,6 +3350,17 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -3570,9 +3749,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001468", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001468.tgz", - "integrity": "sha512-zgAo8D5kbOyUcRAgSmgyuvBkjrGk5CGYG5TYgFdpQv+ywcyEpo1LOWoG8YmoflGnh+V+UsNuKYedsoYs0hzV5A==", + "version": "1.0.30001578", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001578.tgz", + "integrity": "sha512-J/jkFgsQ3NEl4w2lCoM9ZPxrD+FoBNJ7uJUpGVjIg/j0OwJosWM36EPDv+Yyi0V4twBk9pPmlFS+PLykgEvUmg==", "funding": [ { "type": "opencollective", @@ -3581,6 +3760,10 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ] }, @@ -4652,6 +4835,27 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-xml-parser": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.3.3.tgz", + "integrity": "sha512-coV/D1MhrShMvU6D0I+VAK3umz6hUaxxhL0yp/9RjfiYUfAv14rDhGQL+PLForhMdr0wq3PiV07WtkkNjJjNHg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + } + ], + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -5173,6 +5377,19 @@ "node": ">=8.0.0" } }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/http-signature": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz", @@ -5186,6 +5403,18 @@ "node": ">=0.10" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", @@ -7667,6 +7896,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" + }, "node_modules/styled-jsx": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", @@ -7985,9 +8219,13 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } diff --git a/package.json b/package.json index 8be4dbb5..cca571f5 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "@apollo/client": "^3.7.14", + "@azure/data-tables": "^13.2.2", "@chakra-ui/icons": "^2.0.19", "@chakra-ui/next-js": "^2.1.4", "@chakra-ui/react": "^2.7.1", @@ -56,6 +57,7 @@ "react-icons": "4.8.0", "react-lite-youtube-embed": "^2.3.52", "typescript": "^4.9.5", + "uuid": "^9.0.1", "web-vitals": "^2.1.4" }, "devDependencies": { @@ -63,11 +65,13 @@ "@types/node": "^16.18.16", "@types/react": "^18.0.28", "@types/react-dom": "^18.0.11", + "@types/uuid": "^9.0.7", "concurrently": "^8.2.0", "eslint": "^8.41.0" }, "scripts": { "dev": "next dev", + "dev:azurite": "concurrently --kill-others \"npm run dev\" \"azurite\"", "cypress": "concurrently --kill-others \"npm run dev\" \"npx cypress open\"", "build": "next build", "start": "next start", diff --git a/src/GraphQL/Queries/Media.gql b/src/GraphQL/Queries/Media.gql new file mode 100644 index 00000000..c7975d34 --- /dev/null +++ b/src/GraphQL/Queries/Media.gql @@ -0,0 +1,11 @@ +fragment MediaFragment on Media { + id + fileUrl + fileName +} + +query GetMediaByIdQuery($id: String!) { + media(id: $id) { + ...MediaFragment + } +} diff --git a/src/components/Contexts/GameInfoContext/GameInfoContext.tsx b/src/components/Contexts/GameInfoContext/GameInfoContext.tsx index d7bf66a2..0e4a5322 100644 --- a/src/components/Contexts/GameInfoContext/GameInfoContext.tsx +++ b/src/components/Contexts/GameInfoContext/GameInfoContext.tsx @@ -1,9 +1,8 @@ import { ITrait, useTrait } from 'hooks/useTrait'; -import { OutcomeService } from 'lib/OutcomeService'; import { PersonaService } from 'lib/PersonaService'; import { ThemeService } from 'lib/ThemeService'; -import { IAnswer, IImage, IOutcome, IPersona, IPrompt, ITheme } from 'models'; -import React, { FC, createContext, useEffect } from 'react'; +import { IAnswer, IImage, IPersona, IPrompt, ITheme } from 'models'; +import React, { FC, createContext, useCallback, useEffect } from 'react'; export const GameInfoContext = createContext({} as GameInfoContextType); @@ -11,9 +10,8 @@ export interface GameInfoContextType { theme: ITheme | undefined; persona: IPersona | undefined; answers?: IAnswer[] | undefined; - outcome: IOutcome | undefined; avatar: IImage | undefined; - updateAnswers: (answers: IAnswer[]) => void; + updateAnswers: (answers: IAnswer[]) => Promise; updateAvatar: (avatar: IImage) => void; updatePersona: (persona: string) => void; updateTheme: (theme: string) => void; @@ -31,30 +29,35 @@ export const GameInfoProvider: FC = ({ children }) => { const savedAnswers = useTrait([]); const themes = useTrait(); const personas = useTrait(); - const outcomes = useTrait(); const avatars = useTrait(); const questionTrait = useTrait([]); //const [theme, setTheme] = useState('-e_W0k2zO0uZPNBmYtorCQ'); //const [persona, setPersona] = useState('nMeJvakIB0Kvx29f5uVdiw'); - useEffect(() => { - const initialize = async () => { - let persona = await PersonaService().GetPersonaById('nMeJvakIB0Kvx29f5uVdiw'); - - if (persona) { - personas.set(persona); - } - }; + const initialize = useCallback(async () => { + let persona = await PersonaService().GetPersonaById('nMeJvakIB0Kvx29f5uVdiw'); - initialize().catch((e) => console.error(e)); + if (persona) { + personas.set(persona); + } }, []); - const updateAnswers = (promptAnswers: IAnswer[]) => { - if (savedAnswers.get() && savedAnswers.get().length > 0) { - savedAnswers.set([...savedAnswers.get(), ...promptAnswers]); - } else { - savedAnswers.set(promptAnswers); - } + useEffect(() => { + initialize().catch((e: any) => console.error(e)); + }, [initialize]); + + const updateAnswers = async (promptAnswers: IAnswer[]): Promise => { + return new Promise((resolve) => { + let newAnswers = savedAnswers.get(); + + if (newAnswers && newAnswers.length > 0) { + newAnswers = [...newAnswers, ...promptAnswers]; + } else { + newAnswers = promptAnswers; + } + savedAnswers.set(newAnswers); + resolve(newAnswers); + }); }; const resetAnswers = () => { @@ -67,13 +70,6 @@ export const GameInfoProvider: FC = ({ children }) => { if (result) { themes.set(result); } - - //Get the outcome for the new theme. For now, we are using the first match even if multiple are returned. - const outcomeResult = await OutcomeService().GetOutcomeByTheme(id); - if (outcomeResult && outcomeResult.results.length > 0) { - let outcome = outcomeResult.results[0]; - outcomes.set(outcome); - } }; const handlePersonaUpdate = async (id: string) => { @@ -97,9 +93,8 @@ export const GameInfoProvider: FC = ({ children }) => { persona: personas.get(), answers: savedAnswers.get(), questionsBank: questionTrait, - outcome: outcomes.get(), avatar: avatars.get(), - updateAnswers: (promptAnswers: IAnswer[]) => updateAnswers(promptAnswers), + updateAnswers: async (promptAnswers: IAnswer[]) => await updateAnswers(promptAnswers), resetAnswers: () => resetAnswers(), updateTheme: async (id: string) => { await handleThemeUpdate(id); diff --git a/src/components/Outcomes/OutcomeGenerator/OutcomeGenerator.tsx b/src/components/Outcomes/OutcomeGenerator/OutcomeGenerator.tsx index 4f2c402c..9ce86b7d 100644 --- a/src/components/Outcomes/OutcomeGenerator/OutcomeGenerator.tsx +++ b/src/components/Outcomes/OutcomeGenerator/OutcomeGenerator.tsx @@ -11,20 +11,56 @@ import { } from '@chakra-ui/react'; import { useGameInfoContext } from 'components/Contexts'; import { ConditionalResponse } from 'components/Outcomes'; -import { LinkCard, RichTextOutput, YouTubeVideoDisplay } from 'components/ui'; +import { LinkCard, Loading, RichTextOutput, YouTubeVideoDisplay } from 'components/ui'; +import { OutcomeService } from 'lib/OutcomeService'; +import { IOutcome } from 'models'; import { ExperienceEdgeOption, OutcomeConditions, TargetProduct } from 'models/OutcomeConditions'; -import { FC } from 'react'; +import { FC, useCallback, useEffect, useState } from 'react'; interface OutcomeGeneratorProps {} export const OutcomeGenerator: FC = () => { const gameInfoContext = useGameInfoContext(); + const [outcome, setOutcome] = useState(); + const [loading, setLoading] = useState(true); + const outcomeService = OutcomeService(); - //If there is no Outcome information in the Game Info Context, we cannot output this page - if (!gameInfoContext.outcome || !gameInfoContext.outcome.productsIntro) { - let errorMessage = 'Missing Outcome content for current theme: ' + gameInfoContext.theme; - console.error(errorMessage); - return
{errorMessage}
; + // Pull Outcome Content here instead on load + const loadOutcomeResults = useCallback(async () => { + setLoading(true); + // TODO: Default to Corporate theme, will need to fix this line when theme switching is implemented again + const outcomeResult = await outcomeService.GetOutcomeByTheme('fk_VuvA0wkCqEF6Yx1zolQ'); + + if (outcomeResult?.results) { + // Only set outcome with the first result + if (outcomeResult.results.length > 0) { + setOutcome(outcomeResult.results[0]); + } + } + setLoading(false); + }, []); + + useEffect(() => { + loadOutcomeResults(); + }, [loadOutcomeResults]); + + if (loading || outcome === undefined) { + return ( +
+ +
+ ); + } + + if (outcome === undefined) { + return ( +
+ + No outcome found + + No outcome was found for the answers you provided. Please try again. +
+ ); } //Use the OutcomeConditions class for storing all the answers as the conditions we'll use in the logic. @@ -35,11 +71,11 @@ export const OutcomeGenerator: FC = () => { return ( <> - {gameInfoContext.outcome.title} + {outcome.title} - + {requiredProducts && requiredProducts.length > 0 && ( @@ -54,11 +90,9 @@ export const OutcomeGenerator: FC = () => { - {gameInfoContext.outcome?.outcomeReasons.results.find((r) => r.product === product) != undefined ? ( + {outcome?.outcomeReasons.results.find((r) => r.product === product) != undefined ? ( r.product === product)?.reason! - } + content={outcome?.outcomeReasons.results.find((r) => r.product === product)?.reason!} /> ) : ( <> @@ -70,14 +104,14 @@ export const OutcomeGenerator: FC = () => { )} - {gameInfoContext.outcome.videoTitle} + {outcome.videoTitle} - + - + - + @@ -96,10 +130,10 @@ export const OutcomeGenerator: FC = () => { - {gameInfoContext.outcome.xcFeaturesTitle} + {outcome.xcFeaturesTitle} - + @@ -181,10 +215,10 @@ export const OutcomeGenerator: FC = () => { - {gameInfoContext.outcome.xpFeaturesTitle} + {outcome.xpFeaturesTitle} - + @@ -215,10 +249,10 @@ export const OutcomeGenerator: FC = () => { - {gameInfoContext.outcome.xmFeaturesTitle} + {outcome.xmFeaturesTitle} - + {/* Content migration tool */} @@ -304,10 +338,10 @@ export const OutcomeGenerator: FC = () => { - {gameInfoContext.outcome.aspnetHeadlessTitle} + {outcome.aspnetHeadlessTitle} - + = (props) => { + const router = useRouter(); const gameInfoContext = useGameInfoContext(); const tracker = useEngageTracker(); - if (process.browser) { - if (gameInfoContext.answers === undefined || gameInfoContext.answers.length === 0) { - if (!(typeof window === undefined)) { - window.history.pushState(null, '', '/'); - window.location.reload(); - } else { - router.push('/'); - } - } - } - - useEffect(() => { - tracker.TrackPageView( - { page: '/outcome', channel: 'WEB', language: 'EN', currency: 'USD' }, - { - answers: JSON.stringify(gameInfoContext.answers), - } - ); - - GTag.event('outcome_answers', 'Answers', JSON.stringify(gameInfoContext.answers)); - - let outcomeConditions = new OutcomeConditions(gameInfoContext); - const requiredProducts: TargetProduct[] = outcomeConditions.requiredProducts(); - - if (requiredProducts) { - requiredProducts.forEach((product) => { - tracker.TrackEvent('outcome_required_product', { requiredProduct: product }); - GTag.event('outcome_required_product', product, product); - }); - } - }, []); - return ( <> diff --git a/src/components/Outcomes/OutcomeUnavailable/OutcomeUnavailable.tsx b/src/components/Outcomes/OutcomeUnavailable/OutcomeUnavailable.tsx new file mode 100644 index 00000000..8084b8ff --- /dev/null +++ b/src/components/Outcomes/OutcomeUnavailable/OutcomeUnavailable.tsx @@ -0,0 +1,34 @@ +import { Box, Button, Card, Heading, Stack, Text, Tooltip } from '@chakra-ui/react'; +import { useGameInfoContext } from 'components/Contexts'; +import { useRouter } from 'next/router'; +import { FC } from 'react'; +import { MdCached } from 'react-icons/md'; + +interface OutcomeUnavailableProps {} + +export const OutcomeUnavailable: FC = (props) => { + const router = useRouter(); + const gameInfoContext = useGameInfoContext(); + return ( + + + Uh oh! + We were unable to create an outcome based on your answers, please start over and try again. + + + + + + + + ); +}; diff --git a/src/components/Outcomes/index.ts b/src/components/Outcomes/index.ts index c158bbdb..e102f493 100644 --- a/src/components/Outcomes/index.ts +++ b/src/components/Outcomes/index.ts @@ -3,3 +3,4 @@ export * from './ConditionalResponse/ConditionalResponse'; export * from './MarkdownDisplay/MarkdownDisplay'; export * from './OutcomeGenerator/OutcomeGenerator'; export * from './OutcomePanel/OutcomePanel'; +export * from './OutcomeUnavailable/OutcomeUnavailable'; diff --git a/src/components/Prompts/CurrentPrompt/CurrentPrompt.tsx b/src/components/Prompts/CurrentPrompt/CurrentPrompt.tsx index 8bf515a0..bb6607b0 100644 --- a/src/components/Prompts/CurrentPrompt/CurrentPrompt.tsx +++ b/src/components/Prompts/CurrentPrompt/CurrentPrompt.tsx @@ -12,7 +12,7 @@ interface PromptProps { export const CurrentPrompt: FC = ({ prompt, answerSelected }) => { const gameInfoContext = useGameInfoContext(); - const optionSelected = (e: React.MouseEvent) => { + const optionSelected = async (e: React.MouseEvent) => { const option = prompt?.options?.results.find((o: IOption) => o.value === e.currentTarget.value); if (option === undefined) { return; @@ -21,24 +21,20 @@ export const CurrentPrompt: FC = ({ prompt, answerSelected }) => { let answer: IAnswer = { promptId: prompt!.id, promptQuestionId: prompt!.questionId, - prompt: prompt!.question, value: new Array(option.value), - valuePrettyText: new Array(option.label), }; - answerSelected(answer); + await answerSelected(answer); }; - const multiSelectSubmit = (selectedOptions: IOption[]) => { + const multiSelectSubmit = async (selectedOptions: IOption[]) => { let answer: IAnswer = { promptId: prompt!.id, promptQuestionId: prompt!.questionId, - prompt: prompt!.question, value: selectedOptions.map((o) => o.value), - valuePrettyText: selectedOptions.map((o) => o.label), }; - answerSelected(answer); + await answerSelected(answer); }; return ( diff --git a/src/components/Prompts/PreviousAnswers/PreviousAnswers.tsx b/src/components/Prompts/PreviousAnswers/PreviousAnswers.tsx deleted file mode 100644 index 8d2d5c55..00000000 --- a/src/components/Prompts/PreviousAnswers/PreviousAnswers.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Box, Button, Divider, ListItem, Text, UnorderedList, useDisclosure } from '@chakra-ui/react'; -import { useGameInfoContext } from 'components/Contexts'; -import { IAnswer } from 'models'; -import { FC } from 'react'; - -interface PreviousAnswersProps {} - -export const PreviousAnswers: FC = () => { - const gameInfoContext = useGameInfoContext(); - const { getDisclosureProps, getButtonProps } = useDisclosure(); - - return ( - <> - {gameInfoContext.answers !== undefined && gameInfoContext.answers.length > 0 && ( - <> - - - - - - {gameInfoContext.answers.length > 0 && ( -
    - {gameInfoContext.answers - .slice() - .reverse() - .map((answer: IAnswer) => ( - - {answer.prompt} - - {answer.valuePrettyText.map((text, i) => ( - {text} - ))} - - - ))} -
- )} -
-
- - )} - - ); -}; diff --git a/src/components/Prompts/PromptPanel/PromptPanel.tsx b/src/components/Prompts/PromptPanel/PromptPanel.tsx index 524f76f2..42005f0f 100644 --- a/src/components/Prompts/PromptPanel/PromptPanel.tsx +++ b/src/components/Prompts/PromptPanel/PromptPanel.tsx @@ -6,10 +6,12 @@ import AvatarDisplay from 'components/ui/AvatarDisplay/AvatarDisplay'; import * as GTag from 'lib/GTag'; import { GetNextPrompts } from 'lib/NextPrompts'; import { PromptService } from 'lib/PromptService'; +import { v4 as uuidv4 } from 'uuid'; import { IAnswer, IPrompt } from 'models'; import router from 'next/router'; import { FC, useEffect, useState } from 'react'; +import { AzureProxyService } from 'services/AzureTable/AzureProxyService'; interface PromptPanelProps extends LayoutProps {} @@ -72,34 +74,73 @@ export const PromptPanel: FC = (props) => { } }; - const triggerNextPrompt = async () => { - // Next Prompt is based on Pool of Questions that are not answered yet, Collection is FIFO (First In First Out) - if (gameInfoContext.questionsBank?.get() !== undefined) { - if (gameInfoContext.questionsBank.get()!.length > 0) { - const questionQueue = gameInfoContext.questionsBank.get(); - const nextPrompt = questionQueue!.shift(); - gameInfoContext.questionsBank.set(questionQueue); + const processOutcomeUrl = async (answers: IAnswer[]) => { + setLoading(true); - setCurrentPrompt(nextPrompt); + // custom event tracking for "generated_outcome" event + await trackGenerateOutcomeEvent(); - await trackPromptPageView(nextPrompt); + let jsonPayload = { + answers: answers, + avatarId: gameInfoContext.avatar?.id, + personaId: gameInfoContext.persona?.id, + themeId: gameInfoContext.theme?.id, + }; + + // Check if JSON payload (answers, etc) already exists and if it does redirect + const entityResult = await AzureProxyService().getByJsonProxy(JSON.stringify(jsonPayload)); + + if (entityResult?.result?.rowKey) { + return `/outcome/${entityResult.result.rowKey}`; + } + + // JSON Payload doesn't exist so lets create it + const rowKey = uuidv4(); + const postPayload = { + rowKey, + json: jsonPayload, + }; + + const createResult = await AzureProxyService().createEntityProxy(JSON.stringify(postPayload)); + + if (createResult.success) { + return `/outcome/${rowKey}`; + } + + setLoading(false); + }; + + const triggerNextPrompt = async (answers: IAnswer[]) => { + const questionBank = gameInfoContext.questionsBank.get(); + + if (questionBank !== undefined && questionBank.length > 0) { + const questionQueue = gameInfoContext.questionsBank.get(); + const nextPrompt = questionQueue!.shift(); + gameInfoContext.questionsBank.set(questionQueue); + + setCurrentPrompt(nextPrompt); + + await trackPromptPageView(nextPrompt); + } else { + const urlString = await processOutcomeUrl(answers); + + if (urlString) { + router.push(urlString); } else { - router.push('/outcome'); + router.push('/outcome/error'); } - } else { - router.push('/outcome'); } }; const answerSelected = async (answer: IAnswer) => { - saveAnswers(answer); + let updatedAnswers = await saveAnswers(answer); await populateQuestions(answer); - triggerNextPrompt(); + await triggerNextPrompt(updatedAnswers); }; - const saveAnswers = (promptAnswers: IAnswer) => { - gameInfoContext.updateAnswers([promptAnswers]); + const saveAnswers = async (promptAnswers: IAnswer): Promise => { + return await gameInfoContext.updateAnswers([promptAnswers]); }; const populateQuestions = async (answers: IAnswer) => { @@ -126,6 +167,12 @@ export const PromptPanel: FC = (props) => { GTag.pageView(`/prompts/${prompt?.id}`); }; + const trackGenerateOutcomeEvent = async () => { + await tracker.TrackEvent('generated_outcome'); + + GTag.event('generated_outcome', 'Generated Outcome Page', 'true'); + }; + return ( { + const GetMediaById = async (mediaId: string): Promise => { + const { error, data } = await chOneService().query({ + query: GetMediaByIdQuery, + variables: { id: mediaId }, + }); + + if (error) { + console.log(error); + return undefined; + } + + const results = data?.media as IImage; + + return results; + }; + + return { GetMediaById }; +}; diff --git a/src/models/IAnswer.ts b/src/models/IAnswer.ts index 1b98c91d..2510f6c9 100644 --- a/src/models/IAnswer.ts +++ b/src/models/IAnswer.ts @@ -1,7 +1,5 @@ export interface IAnswer { promptId: string; promptQuestionId: string; - prompt: string; - value: string[]; // changing to be filled with Ids not value, likely going to remove - valuePrettyText: string[]; + value: string[]; } diff --git a/src/pages/api/azure/get.ts b/src/pages/api/azure/get.ts new file mode 100644 index 00000000..7ca94944 --- /dev/null +++ b/src/pages/api/azure/get.ts @@ -0,0 +1,27 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { AzureTableService } from 'services/AzureTable/AzureTableService'; + +/** + * This is a POST request at `/api/azure/get` to get a UrlEntity in Azure Table Storage. + * + * @param {NextApiRequest} req - The request object. + * @param {NextApiResponse} res - The response object. + * @returns {void} + */ +const handler = (req: NextApiRequest, res: NextApiResponse) => { + const tableService = new AzureTableService(); + const data = req.body; + + if (req.method === 'POST') { + tableService + .getByJson(JSON.stringify(data)) + .then((result) => { + res.status(200).json({ success: true, result: result }); + }) + .catch((error) => { + res.status(500).json({ success: false, error: error }); + }); + } +}; + +export default handler; diff --git a/src/pages/api/azure/index.ts b/src/pages/api/azure/index.ts new file mode 100644 index 00000000..0e2cf20b --- /dev/null +++ b/src/pages/api/azure/index.ts @@ -0,0 +1,28 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { AzureTableService } from 'services/AzureTable/AzureTableService'; + +/** + * This is a POST request at `/api/azure` to create a new UrlEntity in Azure Table Storage. + * + * @param {NextApiRequest} req - The request object. + * @param {NextApiResponse} res - The response object. + * @returns {void} + */ +const handler = (req: NextApiRequest, res: NextApiResponse) => { + const tableService = new AzureTableService(); + const data = req.body; + + if (req.method === 'POST') { + tableService + .createEntity(data.rowKey, JSON.stringify(data.json)) + .then(() => { + res.status(200).json({ success: true }); + }) + .catch((error) => { + console.log(error); + res.status(500).json({ success: false, error: error }); + }); + } +}; + +export default handler; diff --git a/src/pages/outcome.tsx b/src/pages/outcome.tsx deleted file mode 100644 index 5311a9c3..00000000 --- a/src/pages/outcome.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { OutcomePanel } from 'components/Outcomes'; -import { Layout } from 'components/ui'; - -interface OutcomePageProps {} - -const OutcomePage: React.FC = () => { - return ( - - - - ); -}; - -export default OutcomePage; diff --git a/src/pages/outcome/[rowKey].tsx b/src/pages/outcome/[rowKey].tsx new file mode 100644 index 00000000..8db2c187 --- /dev/null +++ b/src/pages/outcome/[rowKey].tsx @@ -0,0 +1,151 @@ +import { useEngageTracker, useGameInfoContext } from 'components/Contexts'; +import { OutcomePanel, OutcomeUnavailable } from 'components/Outcomes'; +import { Layout, SingleColumnLayout } from 'components/ui'; +import * as GTag from 'lib/GTag'; +import { MediaService } from 'lib/MediaService'; +import { IAnswer, IImage } from 'models'; +import { OutcomeConditions, TargetProduct } from 'models/OutcomeConditions'; +import { GetStaticProps } from 'next'; +import { useRouter } from 'next/router'; +import { FC, useEffect } from 'react'; +import { AzureTableService } from 'services/AzureTable/AzureTableService'; + +interface OutcomeHashPageProps { + answers: IAnswer[]; + persona: string; + avatar: IImage | undefined; + theme: string; + error?: boolean; +} + +// Don't pre-render pages at build time. +export const getStaticPaths = async () => { + return { + paths: [], + fallback: true, + }; +}; + +// Generate Pages when requested +export const getStaticProps: GetStaticProps = async (context) => { + const rowKey = context.params?.rowKey as string; + + if (rowKey === 'error') { + return { props: { error: true } }; + } + + if (rowKey) { + const azureTableService = new AzureTableService(); + + const payload = await azureTableService.getByRowKey(rowKey); + + if (payload) { + const jsonPayload = JSON.parse(payload.json); + + if (jsonPayload) { + const avatarMedia = await MediaService().GetMediaById(jsonPayload.avatarId); + + return { + props: { + answers: jsonPayload.answers, + persona: jsonPayload.personaId, + avatar: avatarMedia ?? '', + theme: jsonPayload.themeId, + error: false, + }, + }; + } + } + } + + return { + props: { error: true }, + }; +}; + +const OutcomeHashPage: FC = (props) => { + const router = useRouter(); + const gameInfoContext = useGameInfoContext(); + const tracker = useEngageTracker(); + + useEffect(() => { + // This will track all outcome pages + // props can be empty on first render, but updates later, this prevents two tracking events from firing + if (props.error || props.answers) { + tracker.TrackPageView( + { page: router.asPath, channel: 'WEB', language: 'EN', currency: 'USD' }, + { + answers: JSON.stringify(props.answers), + error: props.error, + } + ); + } + + if (props.avatar) { + gameInfoContext.updateAvatar(props.avatar); + } + + if (props.theme) { + gameInfoContext.updateTheme(props.theme); + } + + if (props.persona) { + gameInfoContext.updatePersona(props.persona); + } + + if (props.answers) { + if (!gameInfoContext.answers || gameInfoContext.answers.length === 0) { + // If answers already exist, don't update them (Means this is someone completing the quiz vs a page view) + gameInfoContext.updateAnswers(props.answers); + } + + GTag.event('outcome_answers', 'Answers', JSON.stringify(props.answers)); + } + }, [props]); + + useEffect(() => { + // Since Answers is async in nature because they will get updated from the useEffect based on props, we will wait to log event until gameInfoContext.answers is updated + // Could refactor outcomeconditions though in the future, since it doesn't need gameInfoContext, it just needs answers, which you could pass to it. + if (gameInfoContext.answers && gameInfoContext.answers.length > 0) { + let outcomeConditions = new OutcomeConditions(gameInfoContext); + const requiredProducts: TargetProduct[] = outcomeConditions.requiredProducts(); + + if (requiredProducts) { + requiredProducts.forEach((product) => { + tracker.TrackEvent('outcome_required_product', { requiredProduct: product }); + GTag.event('outcome_required_product', product, product); + }); + } + } + }, [gameInfoContext.answers]); + + if (props.error) { + return ( + + + + + + ); + } + + return ( + <> + + + + + ); +}; + +export default OutcomeHashPage; diff --git a/src/services/AzureTable/AzureProxyService.ts b/src/services/AzureTable/AzureProxyService.ts new file mode 100644 index 00000000..ffef3ebc --- /dev/null +++ b/src/services/AzureTable/AzureProxyService.ts @@ -0,0 +1,43 @@ +export const AzureProxyService = () => { + /** + * Get row in Azure Table Storage by json payload + * @param json + * @returns + */ + const getByJsonProxy = async (jsonString: string) => { + const getEntityResult = await fetch(`/api/azure/get`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: jsonString, + }); + + if (getEntityResult.ok) { + let entityResponse = await getEntityResult.json(); + + return entityResponse; + } + }; + + const createEntityProxy = async (jsonString: string) => { + const createEntityResult = await fetch('/api/azure', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: jsonString, + }); + + if (createEntityResult.ok) { + let entityResponse = await createEntityResult.json(); + + return entityResponse; + } + }; + + return { + getByJsonProxy, + createEntityProxy, + }; +}; diff --git a/src/services/AzureTable/AzureTableService.ts b/src/services/AzureTable/AzureTableService.ts new file mode 100644 index 00000000..d0f0c982 --- /dev/null +++ b/src/services/AzureTable/AzureTableService.ts @@ -0,0 +1,73 @@ +import { RestError, TableClient, TableEntityResult, odata } from '@azure/data-tables'; +import { consoleLogger } from 'utils/consoleLogger'; +import { IAzureTableOptions, IUrlEntity } from './models'; + +export class AzureTableService { + private _options: IAzureTableOptions = { + tableName: process.env.AZURE_TABLE_NAME || '', + connectionString: process.env.AZURE_TABLE_CONNECTION_STRING || '', + }; + private _tableClient: TableClient; + private _partitionKey = 'Primary'; + + constructor() { + if (this._options.tableName == '' || this._options.connectionString == '') { + throw new Error( + 'You must provide a table name and connection string to use Azure Table Storage which is a pre-requisite for this app. To learn more about using it, refer to the readme.' + ); + } + + this._tableClient = TableClient.fromConnectionString(this._options.connectionString, this._options.tableName); + } + + public getByRowKey = async (rowKey: string) => { + // Unfortunately, the Azure SDK doesn't return undefined when a resource isn't found but instead errors out. + try { + const results = await this._tableClient.getEntity(this._partitionKey, rowKey); + + consoleLogger(results); + + return results; + } catch (error: any) { + const restError = error as RestError; + + if (restError.name === 'ResourceNotFoundError') { + return undefined; + } + + consoleLogger(error); + } + }; + + public getByJson = async (json: string): Promise | undefined> => { + const results = this._tableClient.listEntities({ + queryOptions: { + filter: odata`json eq '${json}'`, + }, + }); + + consoleLogger('getByJson', results); + + let firstEntity: TableEntityResult | undefined; + for await (const entity of results) { + firstEntity = entity; + break; + } + + return firstEntity; + }; + + public createEntity = async (rowKey: string, json: string) => { + const entity: IUrlEntity = { + partitionKey: this._partitionKey, + rowKey: rowKey, + json: json, + }; + + const results = await this._tableClient.createEntity(entity); + + consoleLogger(results); + + return results; + }; +} diff --git a/src/services/AzureTable/models.ts b/src/services/AzureTable/models.ts new file mode 100644 index 00000000..fa91c9c9 --- /dev/null +++ b/src/services/AzureTable/models.ts @@ -0,0 +1,10 @@ +import { TableEntity } from '@azure/data-tables'; + +export interface IAzureTableOptions { + tableName: string; + connectionString: string; +} + +export interface IUrlEntity extends TableEntity { + json: string; +}