├── .nvmrc ├── .npmrc ├── systems ├── infrastructure │ ├── .babelignore │ ├── .npmrc │ ├── .gitignore │ ├── scripts │ │ ├── pre-push.sh │ │ ├── pre-commit.sh │ │ └── ci │ │ │ ├── test.sh │ │ │ ├── setup.sh │ │ │ └── deploy.sh │ ├── .eslintignore │ ├── Pulumi.yaml │ ├── .lintstagedrc.cjs │ ├── .mocharc.json │ ├── src │ │ ├── aws │ │ │ ├── s3 │ │ │ │ ├── demo.gif │ │ │ │ ├── index.hbs │ │ │ │ ├── s3.spec.ts │ │ │ │ └── index.ts │ │ │ ├── ecr │ │ │ │ ├── Dockerfile │ │ │ │ ├── package.json │ │ │ │ ├── main-lambda.js │ │ │ │ └── index.ts │ │ │ ├── vpc.ts │ │ │ ├── api-gateway.ts │ │ │ ├── rds.ts │ │ │ ├── cloudfront.ts │ │ │ └── lambda.ts │ │ ├── index.ts │ │ └── github-actions.ts │ ├── .eslintrc.cjs │ ├── tsconfig.json │ ├── Pulumi.code-test-dev.yaml │ ├── .babelrc.esm.mjs │ ├── CHANGELOG.md │ ├── README.md │ └── package.json ├── backend │ ├── .npmrc │ ├── scripts │ │ ├── pre-push.sh │ │ ├── server.sh │ │ ├── dev-setup.sh │ │ ├── pre-commit.sh │ │ ├── ci │ │ │ ├── setup.sh │ │ │ ├── test.sh │ │ │ └── deploy.sh │ │ ├── dev-server.sh │ │ └── docker │ │ │ ├── setup-local-postgres.sql │ │ │ └── setup-local-s3.sh │ ├── .eslintignore │ ├── main-lambda.js │ ├── .lintstagedrc.js │ ├── tsconfig.build.json │ ├── README.md │ ├── src │ │ ├── game-gallery │ │ │ ├── __fixtures__ │ │ │ │ ├── elden-ring.jpeg │ │ │ │ ├── guitar-hero.jpg │ │ │ │ └── devil-may-cry.jpg │ │ │ ├── dto │ │ │ │ ├── upload-game-box-art.args.ts │ │ │ │ ├── get-game.args.ts │ │ │ │ ├── get-game-list.args.ts │ │ │ │ └── add-game-to-library.args.ts │ │ │ ├── models │ │ │ │ ├── prepare-result.model.ts │ │ │ │ ├── game.entity.ts │ │ │ │ └── game.model.ts │ │ │ ├── game-gallery.module.ts │ │ │ ├── game.resolver.ts │ │ │ ├── game.service.ts │ │ │ └── game-gallery.spec.ts │ │ ├── logging │ │ │ ├── formats │ │ │ │ ├── err.ts │ │ │ │ ├── graphql.ts │ │ │ │ └── http.ts │ │ │ ├── logging.constants.ts │ │ │ ├── logging.module.ts │ │ │ ├── logger.ts │ │ │ ├── nest-logger.ts │ │ │ ├── db-operation-logger.ts │ │ │ ├── general-logging.interceptor.ts │ │ │ └── general-logging.interceptor.spec.ts │ │ ├── common │ │ │ ├── common.module.ts │ │ │ ├── request-start-time.middleware.ts │ │ │ ├── request-id.middleware.ts │ │ │ ├── request-start-time.middleware.spec.ts │ │ │ ├── date.sclar.ts │ │ │ └── request-id.middleware.spec.ts │ │ ├── test-helpers │ │ │ ├── get-graphql-error.ts │ │ │ ├── jest │ │ │ │ ├── get-test-state.ts │ │ │ │ ├── jest.module.ts │ │ │ │ ├── jest-test-state.provider.ts │ │ │ │ ├── e2e-global-setup.js │ │ │ │ └── e2e-test-environment.js │ │ │ ├── get-request-agent.ts │ │ │ ├── create-request-agent.ts │ │ │ ├── seeder │ │ │ │ ├── seeder.module.ts │ │ │ │ ├── seeder.service.ts │ │ │ │ └── seeder.controller.ts │ │ │ ├── expect-response-code.ts │ │ │ ├── get-apollo-server.ts │ │ │ └── nest-app-context.ts │ │ ├── config │ │ │ ├── config.constants.ts │ │ │ ├── getEnvFilePath.ts │ │ │ └── configuration.ts │ │ ├── health-check │ │ │ ├── health.module.ts │ │ │ ├── health.controller.ts │ │ │ └── health.spec.ts │ │ ├── error-hanlding │ │ │ ├── apollo.exception.ts │ │ │ ├── error-code.constant.ts │ │ │ ├── not-found.exception.ts │ │ │ ├── bad-request.exception.ts │ │ │ ├── internal-server-error.exception.ts │ │ │ ├── exception-payload.ts │ │ │ ├── unauthorized.exception.ts │ │ │ ├── general-exception.filter.ts │ │ │ └── general-exception.filter.spec.ts │ │ ├── main.http.ts │ │ ├── main.lambda.ts │ │ ├── bootstrap.ts │ │ ├── database │ │ │ └── database.module.ts │ │ ├── migrations │ │ │ └── 1647947240838-create-game-table.ts │ │ └── app.module.ts │ ├── .eslintrc.js │ ├── nest-cli.json │ ├── nest-cli.lambda.json │ ├── .env.development │ ├── .env.test │ ├── webpack.config.js │ ├── tsconfig.json │ ├── ormconfig.cli.js │ ├── Dockerfile │ ├── .gitignore │ ├── docker-compose.yml │ ├── Dockerfile.lambda │ ├── docker-compose-test.yml │ ├── schema.graphql │ ├── CHANGELOG.md │ └── package.json └── frontend │ ├── .npmrc │ ├── .env │ ├── scripts │ ├── pre-push.sh │ ├── ci │ │ ├── setup.sh │ │ ├── deploy.sh │ │ └── test.sh │ ├── dev-server.sh │ ├── dev-setup.sh │ └── pre-commit.sh │ ├── src │ ├── react-app-env.d.ts │ ├── theme.css │ ├── get-env.ts │ ├── GameLibraryPage │ │ ├── user.ts │ │ ├── GameList.provider.tsx │ │ ├── AddGameLibrary.form.spec.tsx │ │ └── GameLibrary.page.tsx │ ├── index.tsx │ ├── reportWebVitals.js │ ├── App.tsx │ ├── GlobalContext.provider.tsx │ ├── ApolloClient.provider.tsx │ ├── Layout │ │ ├── psn_icon.svg │ │ ├── Layout.tsx │ │ └── sony_logo.svg │ ├── favicon.svg │ └── Theme.provider.tsx │ ├── .eslintignore │ ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── index.html │ ├── cypress │ ├── fixtures │ │ ├── elden-ring.jpeg │ │ ├── guitar-hero.jpg │ │ └── devil-may-cry.jpg │ ├── tsconfig.json │ ├── support │ │ ├── commands.ts │ │ └── index.ts │ ├── global.d.ts │ └── plugins │ │ └── index.ts │ ├── .lintstagedrc.js │ ├── tsconfig.json │ ├── stylelint.config.js │ ├── .gitignore │ ├── .eslintrc.js │ ├── cypress.json │ ├── package.json │ └── README.md ├── lerna.json ├── .markdownlint-cli2.cjs ├── .prettierrc.js ├── scripts ├── setup.sh ├── bump-version.sh └── lint.sh ├── .eslintrc.js ├── .husky ├── commit-msg ├── pre-push └── pre-commit ├── docs ├── aws-usd-budget.png ├── working-screenshot.png └── adr │ ├── 0003-page-ux-design │ ├── psn.png │ ├── nitendo.png │ ├── xbox-game-pass-game-store.png │ └── README.md │ ├── 0007-deployment │ ├── deploymnet.png │ ├── README.md │ └── deploymnet.puml │ ├── 0005-plain-css-or-ui-library.md │ ├── 0002-ssr-or-csr.md │ ├── 0008-file-upload.md │ ├── 0006-express-or-nestjs.md │ ├── 0001-game-gallery-database-table-structure.md │ ├── 0004-graphql-or-rest.md │ └── template.md ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── test-workspace.yml │ ├── auto-approve.yml │ ├── auto-merge.yml │ ├── test-backend.yml │ ├── test-frontend.yml │ ├── test-infrastructure.yml │ ├── deploy-frontend.yml │ └── deploy-backend.yml ├── commitlint.config.js ├── .gitignore ├── package.json ├── README.md └── CHANGELOG.md /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/Fermium -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /systems/infrastructure/.babelignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /systems/backend/.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /systems/frontend/.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /systems/infrastructure/.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /systems/infrastructure/.gitignore: -------------------------------------------------------------------------------- 1 | /bin/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /systems/frontend/.env: -------------------------------------------------------------------------------- 1 | REACT_APP_BACKEND_HOST=http://localhost:5333 -------------------------------------------------------------------------------- /systems/backend/scripts/pre-push.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -ex 4 | -------------------------------------------------------------------------------- /systems/backend/scripts/server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | npm run start -------------------------------------------------------------------------------- /systems/frontend/scripts/pre-push.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -ex 4 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["systems/*"], 3 | "version": "1.3.0" 4 | } 5 | -------------------------------------------------------------------------------- /systems/frontend/scripts/ci/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | npm run build -------------------------------------------------------------------------------- /systems/frontend/scripts/dev-server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | npm run start -------------------------------------------------------------------------------- /systems/frontend/scripts/dev-setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | echo "No Op" -------------------------------------------------------------------------------- /systems/frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /systems/infrastructure/scripts/pre-push.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -ex 4 | -------------------------------------------------------------------------------- /.markdownlint-cli2.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ignores: ['CHANGELOG.md'], 3 | }; 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('@busybox/prettier-config'), 3 | }; -------------------------------------------------------------------------------- /scripts/setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | npm install 4 | npx lerna bootstrap 5 | -------------------------------------------------------------------------------- /systems/backend/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | package-lock.json -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@busybox'], 3 | root: true, 4 | }; 5 | -------------------------------------------------------------------------------- /systems/backend/scripts/dev-setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | docker-compose up -d 5 | -------------------------------------------------------------------------------- /systems/backend/scripts/pre-commit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -ex 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /systems/frontend/scripts/pre-commit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -ex 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /systems/frontend/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | coverage/ 4 | package-lock.json 5 | public/ -------------------------------------------------------------------------------- /systems/frontend/src/theme.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-size: 62.5%; /*Make compute rem more easily*/ 3 | } -------------------------------------------------------------------------------- /systems/infrastructure/scripts/pre-commit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -ex 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /systems/backend/scripts/ci/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | npm run build 5 | docker-compose up -d 6 | -------------------------------------------------------------------------------- /systems/backend/scripts/dev-server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | APP_ENV=development npm run start:dev -------------------------------------------------------------------------------- /docs/aws-usd-budget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrusher1023/aws-nest-react/HEAD/docs/aws-usd-budget.png -------------------------------------------------------------------------------- /systems/frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /systems/frontend/src/get-env.ts: -------------------------------------------------------------------------------- 1 | export default function getEnv(key: string) { 2 | return process.env[key]; 3 | } 4 | -------------------------------------------------------------------------------- /docs/working-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrusher1023/aws-nest-react/HEAD/docs/working-screenshot.png -------------------------------------------------------------------------------- /scripts/bump-version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -ex 4 | 5 | npx lerna version --no-push --conventional-commits -------------------------------------------------------------------------------- /systems/infrastructure/scripts/ci/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -ex 4 | 5 | npm run lint:ci 6 | npx tsc 7 | npm run test -------------------------------------------------------------------------------- /systems/infrastructure/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | package-lock.json 5 | ./src/aws/ecr/package.json -------------------------------------------------------------------------------- /docs/adr/0003-page-ux-design/psn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrusher1023/aws-nest-react/HEAD/docs/adr/0003-page-ux-design/psn.png -------------------------------------------------------------------------------- /systems/backend/main-lambda.js: -------------------------------------------------------------------------------- 1 | const { handler } = require('./dist/main.lambda'); 2 | 3 | module.exports = { 4 | handler, 5 | }; 6 | -------------------------------------------------------------------------------- /systems/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrusher1023/aws-nest-react/HEAD/systems/frontend/public/favicon.ico -------------------------------------------------------------------------------- /systems/frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrusher1023/aws-nest-react/HEAD/systems/frontend/public/logo192.png -------------------------------------------------------------------------------- /systems/frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrusher1023/aws-nest-react/HEAD/systems/frontend/public/logo512.png -------------------------------------------------------------------------------- /docs/adr/0007-deployment/deploymnet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrusher1023/aws-nest-react/HEAD/docs/adr/0007-deployment/deploymnet.png -------------------------------------------------------------------------------- /systems/backend/.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.{ts,js,json}': ['npm run eslint -- --fix', 'npx prettier --write'], 3 | }; 4 | -------------------------------------------------------------------------------- /systems/infrastructure/Pulumi.yaml: -------------------------------------------------------------------------------- 1 | name: infrastructure 2 | runtime: nodejs 3 | description: A minimal AWS TypeScript Pulumi for serverless 4 | -------------------------------------------------------------------------------- /docs/adr/0003-page-ux-design/nitendo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrusher1023/aws-nest-react/HEAD/docs/adr/0003-page-ux-design/nitendo.png -------------------------------------------------------------------------------- /systems/backend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"], 3 | "extends": "./tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /systems/infrastructure/.lintstagedrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.{ts,js,json}': ['npm run eslint -- --fix', 'npx prettier --write'], 3 | }; 4 | -------------------------------------------------------------------------------- /systems/infrastructure/.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "loader": "ts-node/esm", 3 | "spec": ["src/**/*.spec.ts"], 4 | "require": "ts-node/register" 5 | } 6 | -------------------------------------------------------------------------------- /systems/infrastructure/src/aws/s3/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrusher1023/aws-nest-react/HEAD/systems/infrastructure/src/aws/s3/demo.gif -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | set -ex 5 | npx lerna exec --concurrency 1 --stream -- bash scripts/pre-push.sh 6 | -------------------------------------------------------------------------------- /systems/backend/scripts/docker/setup-local-postgres.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 2 | CREATE DATABASE dev; 3 | CREATE DATABASE test; 4 | -------------------------------------------------------------------------------- /systems/backend/README.md: -------------------------------------------------------------------------------- 1 | # Backend for display game gallery 2 | 3 | ## Stack 4 | 5 | - [NestJS](https://nestjs.com/) 6 | - [GraphQL](https://graphql.org/) 7 | -------------------------------------------------------------------------------- /systems/frontend/cypress/fixtures/elden-ring.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrusher1023/aws-nest-react/HEAD/systems/frontend/cypress/fixtures/elden-ring.jpeg -------------------------------------------------------------------------------- /systems/frontend/cypress/fixtures/guitar-hero.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrusher1023/aws-nest-react/HEAD/systems/frontend/cypress/fixtures/guitar-hero.jpg -------------------------------------------------------------------------------- /systems/frontend/cypress/fixtures/devil-may-cry.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrusher1023/aws-nest-react/HEAD/systems/frontend/cypress/fixtures/devil-may-cry.jpg -------------------------------------------------------------------------------- /systems/infrastructure/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "root": true, 3 | extends: ['@busybox'], 4 | rules: { 5 | 'no-new': ['off'], 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /systems/infrastructure/scripts/ci/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -ex 4 | export AWS_ACCESS_KEY_ID=test 5 | export AWS_SECRET_ACCESS_KEY=test 6 | 7 | npm install 8 | -------------------------------------------------------------------------------- /systems/infrastructure/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "outDir": "./bin" 5 | }, 6 | "extends": "@busybox/tsconfig" 7 | } 8 | -------------------------------------------------------------------------------- /systems/backend/scripts/docker/setup-local-s3.sh: -------------------------------------------------------------------------------- 1 | #!/bin/env bash 2 | aws --endpoint-url http://localhost:4566 s3api create-bucket --bucket system-assets --acl public-read-write -------------------------------------------------------------------------------- /systems/frontend/.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.{ts,tsx,js,json}': ['npx eslint --fix', 'npx prettier --write'], 3 | '*.{css,tsx}': ['npx stylelint'], 4 | }; 5 | -------------------------------------------------------------------------------- /docs/adr/0003-page-ux-design/xbox-game-pass-game-store.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrusher1023/aws-nest-react/HEAD/docs/adr/0003-page-ux-design/xbox-game-pass-game-store.png -------------------------------------------------------------------------------- /systems/backend/src/game-gallery/__fixtures__/elden-ring.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrusher1023/aws-nest-react/HEAD/systems/backend/src/game-gallery/__fixtures__/elden-ring.jpeg -------------------------------------------------------------------------------- /systems/backend/src/game-gallery/__fixtures__/guitar-hero.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrusher1023/aws-nest-react/HEAD/systems/backend/src/game-gallery/__fixtures__/guitar-hero.jpg -------------------------------------------------------------------------------- /systems/backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@busybox'], 3 | root: true, 4 | rules: { 5 | 'dot-notation': 'off', 6 | 'max-params': 'off', 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /systems/backend/scripts/ci/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -ex 4 | export AWS_ACCESS_KEY_ID=test 5 | export AWS_SECRET_ACCESS_KEY=test 6 | 7 | npm run lint:ci 8 | npx tsc 9 | npm run test:ci -------------------------------------------------------------------------------- /systems/backend/src/game-gallery/__fixtures__/devil-may-cry.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitcrusher1023/aws-nest-react/HEAD/systems/backend/src/game-gallery/__fixtures__/devil-may-cry.jpg -------------------------------------------------------------------------------- /systems/frontend/src/GameLibraryPage/user.ts: -------------------------------------------------------------------------------- 1 | // Hardcoded user id for easily isolate records in DB 2 | const userId = '1ec57d7a-67be-42d0-8a97-07e743e6efbc'; 3 | 4 | export default userId; 5 | -------------------------------------------------------------------------------- /systems/frontend/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["cypress"] 4 | }, 5 | "extends": "@busybox/tsconfig/tsconfig-react.json", 6 | "include": ["**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -ex 4 | 5 | npx lint-staged 6 | npx eslint --ext .json,.yaml,.yml,.ts,.js --ignore-pattern '!.github/' --ignore-pattern systems/ --ignore-pattern package-lock.json . -------------------------------------------------------------------------------- /systems/backend/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "compilerOptions": { 4 | "plugins": ["@nestjs/graphql"] 5 | }, 6 | "entryFile": "main.http", 7 | "sourceRoot": "src" 8 | } 9 | -------------------------------------------------------------------------------- /systems/backend/src/logging/formats/err.ts: -------------------------------------------------------------------------------- 1 | import { serializeError } from 'serialize-error'; 2 | 3 | export function err(error: Error) { 4 | return serializeError(error, { 5 | maxDepth: 3, 6 | }); 7 | } 8 | -------------------------------------------------------------------------------- /systems/backend/nest-cli.lambda.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "compilerOptions": { 4 | "plugins": ["@nestjs/graphql"] 5 | }, 6 | "entryFile": "main.lambda", 7 | "sourceRoot": "src" 8 | } 9 | -------------------------------------------------------------------------------- /systems/backend/src/game-gallery/dto/upload-game-box-art.args.ts: -------------------------------------------------------------------------------- 1 | import { ArgsType, Field } from '@nestjs/graphql'; 2 | 3 | @ArgsType() 4 | export class UploadGameBoxArtArgs { 5 | @Field() 6 | fileName!: string; 7 | } 8 | -------------------------------------------------------------------------------- /systems/backend/src/common/common.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { DateScalar } from './date.sclar'; 4 | 5 | @Module({ 6 | providers: [DateScalar], 7 | }) 8 | export class CommonModule {} 9 | -------------------------------------------------------------------------------- /systems/infrastructure/Pulumi.code-test-dev.yaml: -------------------------------------------------------------------------------- 1 | encryptionsalt: v1:aiuHH/IZbgI=:v1:YyXe5yfBNJRNiy64:QoollibRBeLCfgrYHD/3GLBWzuu1Fw== 2 | config: 3 | aws:region: eu-west-2 4 | prefix:name: code-test 5 | rds:user: dbuser 6 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | set -ex 5 | 6 | bash ./scripts/lint.sh 7 | npx lerna exec --concurrency 1 --stream --since HEAD --exclude-dependents -- bash scripts/pre-commit.sh 8 | -------------------------------------------------------------------------------- /systems/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "types": ["cypress"] 5 | }, 6 | "extends": "@busybox/tsconfig/tsconfig-react.json", 7 | "include": ["src", "cypress/global.d.ts"], 8 | } 9 | -------------------------------------------------------------------------------- /systems/backend/src/test-helpers/get-graphql-error.ts: -------------------------------------------------------------------------------- 1 | import type { GraphQLError } from 'graphql'; 2 | 3 | export function getGraphqlErrorCodes(errors?: GraphQLError[]) { 4 | return errors?.map(error => error.extensions['code']); 5 | } 6 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @davidNHK 2 | **/package.json @github-actions[bot] 3 | **/package-lock.json @github-actions[bot] 4 | .github/*.yml @github-actions[bot] 5 | **/docker-compose-* @github-actions[bot] 6 | **/Dockerfile @github-actions[bot] -------------------------------------------------------------------------------- /systems/backend/.env.development: -------------------------------------------------------------------------------- 1 | DATABASE_CONNECTION_URL=postgres://:dev@localhost:5432/dev 2 | PORT=5333 3 | S3_REGION=eu-west-2 4 | S3_ASSET_BUCKET=system-assets 5 | CLOUDFRONT_URL=http://localhost:4566 6 | APP_MODE=http 7 | APP_ENV=development -------------------------------------------------------------------------------- /systems/backend/.env.test: -------------------------------------------------------------------------------- 1 | NODE_ENV=test 2 | DATABASE_CONNECTION_URL=postgres://:dev@localhost:5432/test 3 | PORT=5333 4 | S3_REGION=eu-west-2 5 | S3_ASSET_BUCKET=system-assets 6 | CLOUDFRONT_URL=http://localhost:4566 7 | APP_MODE=http 8 | APP_ENV=test -------------------------------------------------------------------------------- /systems/backend/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = options => { 2 | return { 3 | ...options, 4 | output: { 5 | ...options.output, 6 | library: { 7 | type: 'commonjs2', 8 | }, 9 | }, 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /systems/frontend/scripts/ci/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | s3_bucket=${S3_BUCKET} 6 | REACT_APP_BACKEND_HOST=${API_HOST} 7 | 8 | REACT_APP_BACKEND_HOST=$VITE_BACKEND_HOST npm run build 9 | aws s3 cp --recursive dist "s3://$s3_bucket/" -------------------------------------------------------------------------------- /systems/infrastructure/scripts/ci/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | npm run build 6 | pulumi stack select code-test/dev 7 | pulumi up 8 | PULUMI_CONFIG_PASSPHRASE= pulumi stack output --json > tmp.json 9 | node ./bin/github-actions.js 10 | rm tmp.json -------------------------------------------------------------------------------- /systems/backend/src/config/config.constants.ts: -------------------------------------------------------------------------------- 1 | export enum AppEnvironment { 2 | CI_TEST = 'ci_test', 3 | DEV = 'development', 4 | PRD = 'production', 5 | TEST = 'test', 6 | } 7 | 8 | export enum AppMode { 9 | HTTP = 'http', 10 | LAMBDA = 'lambda', 11 | } 12 | -------------------------------------------------------------------------------- /systems/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "module": "commonjs", 5 | "noEmit": false, 6 | "outDir": "./dist" 7 | }, 8 | 9 | "exclude": ["node_modules", "dist"], 10 | "extends": "@busybox/tsconfig" 11 | } 12 | -------------------------------------------------------------------------------- /systems/frontend/stylelint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | customSyntax: require('@stylelint/postcss-css-in-js')(), 3 | extends: ['stylelint-config-styled-components'], 4 | plugins: ['stylelint-order'], 5 | rules: { 'order/properties-alphabetical-order': true }, 6 | }; 7 | -------------------------------------------------------------------------------- /systems/frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import App from './App'; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root'), 11 | ); 12 | -------------------------------------------------------------------------------- /systems/backend/ormconfig.cli.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | module.exports = { 4 | cli: { 5 | migrationsDir: 'src/migrations', 6 | }, 7 | migrations: ['dist/migrations/*.js'], 8 | type: 'postgres', 9 | url: process.env.DATABASE_CONNECTION_URL, 10 | }; 11 | -------------------------------------------------------------------------------- /systems/backend/src/game-gallery/dto/get-game.args.ts: -------------------------------------------------------------------------------- 1 | import { ArgsType, Field, ID } from '@nestjs/graphql'; 2 | import { IsNotEmpty } from 'class-validator'; 3 | 4 | @ArgsType() 5 | export class GetGameArgs { 6 | @Field(() => ID) 7 | @IsNotEmpty() 8 | id!: string; 9 | } 10 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | const { 2 | utils: { getPackages }, 3 | } = require('@commitlint/config-lerna-scopes'); 4 | 5 | module.exports = { 6 | rules: { 7 | 'scope-enum': ctx => 8 | getPackages(ctx).then(packages => [2, 'always', [...packages, 'deps']]), 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /systems/frontend/cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | Cypress.Commands.add('getBySel', selector => { 2 | return cy.get(`[data-testid=${selector}]`); 3 | }); 4 | 5 | Cypress.Commands.add('getBySelLike', selector => { 6 | return cy.get(`[data-testid*=${selector}]`); 7 | }); 8 | 9 | export {}; 10 | -------------------------------------------------------------------------------- /systems/infrastructure/src/aws/ecr/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/lambda/nodejs:14 2 | 3 | ARG FUNCTION_DIR="/var/task" 4 | 5 | RUN mkdir -p ${FUNCTION_DIR} 6 | 7 | COPY main-lambda.js package.json package-lock.json ${FUNCTION_DIR} 8 | 9 | WORKDIR ${FUNCTION_DIR} 10 | 11 | RUN npm install --ci 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | updates: 2 | - directory: / 3 | ignore: 4 | - dependency-name: serialize-error 5 | 6 | package-ecosystem: npm 7 | schedule: 8 | interval: daily 9 | - directory: / 10 | package-ecosystem: github-actions 11 | schedule: 12 | interval: daily 13 | version: 2 14 | -------------------------------------------------------------------------------- /systems/infrastructure/src/aws/ecr/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "pg": "8.7.3" 4 | }, 5 | "engines": { 6 | "node": ">=14", 7 | "yarn": "Use npm" 8 | }, 9 | "license": "MIT", 10 | "main": "bin/index.js", 11 | "name": "test-app", 12 | "type": "module", 13 | "version": "0.0.0" 14 | } 15 | -------------------------------------------------------------------------------- /systems/backend/src/game-gallery/models/prepare-result.model.ts: -------------------------------------------------------------------------------- 1 | import { Field, ID, ObjectType } from '@nestjs/graphql'; 2 | 3 | @ObjectType() 4 | export class PrepareUpload { 5 | @Field(() => ID) 6 | id!: string; 7 | 8 | @Field() 9 | resultPublicUrl!: string; 10 | 11 | @Field() 12 | uploadUrl!: string; 13 | } 14 | -------------------------------------------------------------------------------- /systems/backend/src/health-check/health.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TerminusModule } from '@nestjs/terminus'; 3 | 4 | import { HealthController } from './health.controller'; 5 | 6 | @Module({ 7 | controllers: [HealthController], 8 | imports: [TerminusModule], 9 | }) 10 | export class HealthModule {} 11 | -------------------------------------------------------------------------------- /systems/frontend/cypress/global.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Cypress { 2 | interface Chainable { 3 | getBySel( 4 | dataTestAttribute: string, 5 | args?: any, 6 | ): Chainable>; 7 | getBySelLike( 8 | dataTestPrefixAttribute: string, 9 | args?: any, 10 | ): Chainable>; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /systems/backend/src/test-helpers/jest/get-test-state.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@jest/globals'; 2 | 3 | export function getTestName() { 4 | return ( 5 | expect.getState()?.currentTestName?.toLowerCase().split(' ').join('-') ?? 6 | 'n/a' 7 | ); 8 | } 9 | 10 | export function getTestPath() { 11 | return expect.getState().testPath ?? 'n/a'; 12 | } 13 | -------------------------------------------------------------------------------- /systems/backend/src/logging/logging.constants.ts: -------------------------------------------------------------------------------- 1 | // default levels: https://github.com/winstonjs/triple-beam/blob/c2991b2b7ff2297a6b57bce8a8d8b70f4183b019/config/npm.js#L15-L21 2 | export enum Level { 3 | debug = 'debug', 4 | error = 'error', 5 | http = 'http', 6 | info = 'info', 7 | silly = 'silly', 8 | verbose = 'verbose', 9 | warn = 'warn', 10 | } 11 | -------------------------------------------------------------------------------- /systems/frontend/scripts/ci/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -ex 4 | export AWS_ACCESS_KEY_ID=test 5 | export AWS_SECRET_ACCESS_KEY=test 6 | 7 | npm run lint:css 8 | npm run lint:js:ci 9 | npx tsc 10 | npx start-server-and-test \ 11 | 'npx lerna exec --scope "backend" -- NODE_ENV=test bash scripts/server.sh' http-get://localhost:5333/healthz \ 12 | 'npm run test:ci' 13 | -------------------------------------------------------------------------------- /systems/frontend/cypress/plugins/index.ts: -------------------------------------------------------------------------------- 1 | import injectDevServer from '@cypress/react/plugins/react-scripts'; 2 | import PluginConfigOptions = Cypress.PluginConfigOptions; 3 | 4 | /** 5 | * @type {Cypress.PluginConfig} 6 | */ 7 | module.exports = (on: Cypress.PluginEvents, config: PluginConfigOptions) => { 8 | injectDevServer(on, config); 9 | return config; 10 | }; 11 | -------------------------------------------------------------------------------- /systems/backend/src/error-hanlding/apollo.exception.ts: -------------------------------------------------------------------------------- 1 | import { ApolloError } from 'apollo-server-errors'; 2 | 3 | import type { ExceptionPayload } from './exception-payload'; 4 | 5 | export class ApolloException extends ApolloError { 6 | constructor(response: ExceptionPayload) { 7 | super('Graphql Error', response.code, { errors: response?.errors ?? [] }); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /systems/backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Builder Stage 2 | FROM node:17.2.0-alpine3.13 AS builder 3 | 4 | WORKDIR /usr/src/demo 5 | 6 | COPY ./ . 7 | 8 | RUN npm ci --ignore-scripts && \ 9 | npm run build 10 | 11 | # Run stage 12 | FROM node:17.2.0-alpine3.13 13 | 14 | WORKDIR /usr/src/demo 15 | 16 | RUN apk add dumb-init 17 | 18 | COPY --from=builder /usr/src/demo/ ./ 19 | 20 | USER node -------------------------------------------------------------------------------- /systems/backend/src/logging/logging.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | 4 | import { Logger } from './logger'; 5 | import { NestLogger } from './nest-logger'; 6 | 7 | @Module({ 8 | exports: [NestLogger, Logger], 9 | imports: [ConfigModule], 10 | providers: [Logger, NestLogger], 11 | }) 12 | export class LoggingModule {} 13 | -------------------------------------------------------------------------------- /systems/backend/src/config/getEnvFilePath.ts: -------------------------------------------------------------------------------- 1 | import { AppEnvironment } from './config.constants'; 2 | 3 | export function getEnvFilePath() { 4 | const nodeEnv = process.env['APP_ENV'] ?? AppEnvironment.DEV; 5 | if (nodeEnv === AppEnvironment.DEV) { 6 | return '.env.development'; 7 | } 8 | 9 | if (nodeEnv === AppEnvironment.TEST) { 10 | return '.env.test'; 11 | } 12 | 13 | return '.env'; 14 | } 15 | -------------------------------------------------------------------------------- /systems/backend/src/common/request-start-time.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from '@nestjs/common'; 2 | import type { NextFunction, Request, Response } from 'express'; 3 | 4 | @Injectable() 5 | export class RequestStartTimeMiddleware implements NestMiddleware { 6 | use(_: Request, res: Response, next: NextFunction) { 7 | res.locals['startAt'] = new Date().getTime(); 8 | next(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /systems/backend/src/logging/formats/graphql.ts: -------------------------------------------------------------------------------- 1 | import type { GqlExecutionContext } from '@nestjs/graphql'; 2 | import { pick } from 'ramda'; 3 | 4 | export function graphql(ctx: GqlExecutionContext) { 5 | const { args, info, root } = { 6 | args: ctx.getArgs(), 7 | info: ctx.getInfo(), 8 | root: ctx.getRoot(), 9 | }; 10 | return { args, info: pick(['fieldName', 'path', 'operation'])(info), root }; 11 | } 12 | -------------------------------------------------------------------------------- /systems/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /systems/frontend/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /systems/backend/src/test-helpers/jest/jest.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { JestTestStateProvider } from './jest-test-state.provider'; 4 | 5 | @Module({}) 6 | export class JestModule { 7 | static forRoot() { 8 | return { 9 | exports: [JestTestStateProvider], 10 | global: true, 11 | imports: [], 12 | module: JestModule, 13 | providers: [JestTestStateProvider], 14 | }; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /systems/infrastructure/src/aws/vpc.ts: -------------------------------------------------------------------------------- 1 | import * as awsx from '@pulumi/awsx'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import kebabcase from 'lodash.kebabcase'; 4 | 5 | export function createVPC() { 6 | const namePrefix = kebabcase(pulumi.getStack()); 7 | // Allocate a new VPC with the default settings: 8 | const vpc = new awsx.ec2.Vpc(`${namePrefix}-vpc`, { 9 | numberOfAvailabilityZones: 3, 10 | subnets: [{ type: 'private' }], 11 | }); 12 | 13 | return { vpc }; 14 | } 15 | -------------------------------------------------------------------------------- /systems/frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@busybox'], 3 | overrides: [ 4 | { 5 | env: { 6 | 'cypress/globals': true, 7 | }, 8 | extends: ['plugin:cypress/recommended'], 9 | files: ['src/**/*.spec.tsx'], 10 | rules: { 11 | 'cypress/no-pause': 'error', 12 | }, 13 | }, 14 | ], 15 | root: true, 16 | rules: { 17 | 'import/no-default-export': ['off'], 18 | 'import/prefer-default-export': ['error'], 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /systems/backend/src/error-hanlding/error-code.constant.ts: -------------------------------------------------------------------------------- 1 | export enum ErrorCode { 2 | AccessTokenError = 'ERR_ACCESS_TOKEN', 3 | ConnectedProviderCredentialError = 'ERR_CONNECTED_PROVIDER_CREDENTIAL', 4 | ExchangeCodeError = 'ERR_EXCHANGE_CODE', 5 | GrantTypeError = 'ERR_GRANT_TYPE', 6 | RefreshTokenError = 'ERR_REFRESH_TOKEN', 7 | TaskNotFoundError = 'ERR_TASK_NOT_FOUND', 8 | UnhandledError = 'ERR_UNHANDLED', 9 | UserNotFoundError = 'ERR_USER_NOT_FOUND', 10 | ValidationError = 'ERR_VALIDATION', 11 | } 12 | -------------------------------------------------------------------------------- /systems/backend/src/test-helpers/get-request-agent.ts: -------------------------------------------------------------------------------- 1 | import type { INestApplication } from '@nestjs/common'; 2 | import type { SuperAgentTest } from 'supertest'; 3 | import * as request from 'supertest'; 4 | 5 | import { getTestName, getTestPath } from './jest/get-test-state'; 6 | 7 | export function getRequestAgent(app: INestApplication) { 8 | return request.agent(app).set({ 9 | 'X-Test-Name': encodeURI(getTestName()), 10 | 'X-Test-Path': encodeURI(getTestPath()), 11 | }) as unknown as SuperAgentTest; 12 | } 13 | -------------------------------------------------------------------------------- /systems/infrastructure/src/aws/s3/index.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Title 5 | 11 | 12 | 13 |
Loading...
14 | 15 | -------------------------------------------------------------------------------- /systems/backend/src/main.http.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { serializeError } from 'serialize-error'; 3 | 4 | import { bootstrap } from './bootstrap'; 5 | 6 | async function start() { 7 | const app = await bootstrap(); 8 | const config = app.get(ConfigService); 9 | const port = config.get('port')!; 10 | return app.listen(port); 11 | } 12 | 13 | start().catch(e => { 14 | // eslint-disable-next-line no-console 15 | console.error(serializeError(e)); 16 | process.exit(1); 17 | }); 18 | -------------------------------------------------------------------------------- /systems/infrastructure/src/aws/ecr/main-lambda.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-unresolved */ 2 | 3 | import pg from 'pg'; 4 | 5 | const { Client } = pg; 6 | export function handler(event, _, callback) { 7 | const client = new Client({ 8 | connectionString: process.env.DATABASE_CONNECTION_URL, 9 | }); 10 | client.connect(); 11 | client.query('SELECT NOW()', (err, res) => { 12 | client.end(); 13 | if (err) return callback(err); 14 | return callback(null, { env: process.env, now: res.rows[0] }); 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /systems/backend/src/game-gallery/game-gallery.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | 5 | import { GameResolver } from './game.resolver'; 6 | import { GameService } from './game.service'; 7 | import { GameEntity } from './models/game.entity'; 8 | 9 | @Module({ 10 | imports: [ConfigModule, TypeOrmModule.forFeature([GameEntity])], 11 | providers: [GameService, GameResolver], 12 | }) 13 | export class GameGalleryModule {} 14 | -------------------------------------------------------------------------------- /systems/backend/src/logging/formats/http.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from 'express'; 2 | 3 | export function http(request: Request, response: Response & { body: any }) { 4 | return { 5 | method: request?.method, 6 | params: request?.params, 7 | query: request?.query, 8 | referer: request?.headers?.referer, 9 | request_id: response?.locals?.['reqId'], 10 | status_code: response?.statusCode, 11 | url: request?.route?.path ?? request?.url, 12 | useragent: request?.headers?.['user-agent'], 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /systems/backend/src/test-helpers/jest/jest-test-state.provider.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { randomUUID } from 'crypto'; 3 | 4 | @Injectable() 5 | export class JestTestStateProvider { 6 | private logger = new Logger(JestTestStateProvider.name); 7 | 8 | private randomTestId = randomUUID().replace(/-/g, ''); 9 | 10 | get testConfig() { 11 | // @ts-expect-error No type here 12 | return global['testConfig'] ?? {}; 13 | } 14 | 15 | get testId() { 16 | return this.randomTestId; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /systems/frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import CssBaseline from '@mui/material/CssBaseline'; 2 | 3 | import GameLibraryPage from './GameLibraryPage/GameLibrary.page'; 4 | import GlobalContextProvider from './GlobalContext.provider'; 5 | import Layout from './Layout/Layout'; 6 | 7 | function App() { 8 | return ( 9 | <> 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | 20 | export default App; 21 | -------------------------------------------------------------------------------- /systems/backend/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json -------------------------------------------------------------------------------- /systems/infrastructure/src/aws/ecr/index.ts: -------------------------------------------------------------------------------- 1 | import * as awsx from '@pulumi/awsx'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import kebabcase from 'lodash.kebabcase'; 4 | import path from 'path'; 5 | 6 | const currentDir = path.parse(new URL(import.meta.url).pathname).dir; 7 | 8 | export function createECRImage() { 9 | const namePrefix = kebabcase(pulumi.getStack()); 10 | const image = awsx.ecr.buildAndPushImage(`${namePrefix}-image`, { 11 | context: path.join(currentDir), 12 | extraOptions: ['--quiet'], 13 | }); 14 | return { image }; 15 | } 16 | -------------------------------------------------------------------------------- /systems/backend/src/test-helpers/create-request-agent.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@jest/globals'; 2 | import type { INestApplication } from '@nestjs/common'; 3 | import type { SuperAgentTest } from 'supertest'; 4 | import * as request from 'supertest'; 5 | 6 | export function createRequestAgent(app: INestApplication) { 7 | const { currentTestName = 'N/A', testPath = 'N/A' } = expect.getState(); 8 | return request.agent(app).set({ 9 | 'X-Test-Name': encodeURI(currentTestName), 10 | 'X-Test-Path': encodeURI(testPath), 11 | }) as unknown as SuperAgentTest; 12 | } 13 | -------------------------------------------------------------------------------- /systems/backend/src/common/request-id.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from '@nestjs/common'; 2 | import type { NextFunction, Request, Response } from 'express'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | 5 | @Injectable() 6 | export class RequestIdMiddleware implements NestMiddleware { 7 | use(request: Request, res: Response, next: NextFunction) { 8 | const reqId = 9 | request.get('REQ-ID') || request.get('X-Amz-Cf-Id') || uuidv4(); 10 | res.locals['reqId'] = reqId; 11 | res.setHeader('request-id', reqId); 12 | next(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /systems/backend/src/common/request-start-time.middleware.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, jest } from '@jest/globals'; 2 | 3 | import { RequestStartTimeMiddleware } from './request-start-time.middleware'; 4 | 5 | describe('Test RequestIdMiddleware', () => { 6 | it("set request id from request header['REQ-ID']", () => { 7 | const request: any = {}; 8 | const response: any = { 9 | locals: {}, 10 | }; 11 | new RequestStartTimeMiddleware().use(request, response, jest.fn() as any); 12 | expect(response.locals.startAt).toBeDefined(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /systems/infrastructure/.babelrc.esm.mjs: -------------------------------------------------------------------------------- 1 | const typescript = ["@babel/preset-typescript", {}] 2 | 3 | const esModules = ["@babel/preset-env", { 4 | "targets": { 5 | "esmodules": true 6 | }, 7 | "modules": false 8 | }] 9 | 10 | 11 | 12 | const config = { 13 | "presets": [ 14 | esModules, typescript], 15 | "sourceMaps": true, 16 | "plugins": [ ["@babel/plugin-proposal-decorators", { "legacy": true }], 17 | "babel-plugin-transform-typescript-metadata", 18 | ["@babel/plugin-proposal-class-properties"], 19 | ] 20 | } 21 | 22 | export default config -------------------------------------------------------------------------------- /systems/backend/src/test-helpers/seeder/seeder.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | 5 | import { GameEntity } from '../../game-gallery/models/game.entity'; 6 | import { SeederController } from './seeder.controller'; 7 | import { SeederService } from './seeder.service'; 8 | 9 | @Module({ 10 | controllers: [SeederController], 11 | imports: [TypeOrmModule.forFeature([GameEntity]), ConfigModule], 12 | providers: [SeederService], 13 | }) 14 | export class SeederModule {} 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | coverage/ 18 | systems/api/coverage/ 19 | systems/api/src/test/coverage/ 20 | 21 | /.nyc_output 22 | 23 | # IDEs and editors 24 | /.idea 25 | .project 26 | .classpath 27 | .c9/ 28 | *.launch 29 | .settings/ 30 | *.sublime-workspace 31 | 32 | # IDE - VSCode 33 | .vscode/* 34 | !.vscode/settings.json 35 | !.vscode/tasks.json 36 | !.vscode/launch.json 37 | !.vscode/extensions.json 38 | .access.log -------------------------------------------------------------------------------- /systems/frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /docs/adr/0005-plain-css-or-ui-library.md: -------------------------------------------------------------------------------- 1 | # Plain CSS or UI Library for styling 2 | 3 | - Status: accepted 4 | 5 | ## Context and Problem Statement 6 | 7 | I need styling html element when develop frontend web application. 8 | 9 | It would include styling on spacing, color, sizing ...etc. 10 | 11 | ## Decision Drivers 12 | 13 | - Easy to use 14 | - Maintainable 15 | 16 | ## Considered Options 17 | 18 | - Plain CSS 19 | - MUI 20 | - CSS in JS (styled component / emotion ) 21 | 22 | ## Decision Outcome 23 | 24 | - MUI because it provides out of box customizable theming system 25 | and easily adjust style by the `sx` system 26 | -------------------------------------------------------------------------------- /systems/backend/src/health-check/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { ApiTags } from '@nestjs/swagger'; 3 | import { 4 | HealthCheck, 5 | HealthCheckService, 6 | TypeOrmHealthIndicator, 7 | } from '@nestjs/terminus'; 8 | 9 | @Controller('/healthz') 10 | @ApiTags('Health Check') 11 | export class HealthController { 12 | constructor( 13 | private readonly health: HealthCheckService, 14 | private readonly db: TypeOrmHealthIndicator, 15 | ) {} 16 | 17 | @Get() 18 | @HealthCheck() 19 | check() { 20 | return this.health.check([() => this.db.pingCheck('database')]); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/test-workspace.yml: -------------------------------------------------------------------------------- 1 | env: 2 | CI: true 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v6 8 | - uses: actions/setup-node@v6.1.0 9 | with: 10 | cache: npm 11 | cache-dependency-path: '**/package-lock.json' 12 | node-version-file: .nvmrc 13 | - run: bash ./scripts/setup.sh 14 | - run: bash ./scripts/lint.sh 15 | name: Test workspace 16 | 17 | on: 18 | pull_request: 19 | paths-ignore: 20 | - 'systems/**' 21 | push: 22 | branches: 23 | - development 24 | - master 25 | paths-ignore: 26 | - 'systems/**' 27 | -------------------------------------------------------------------------------- /.github/workflows/auto-approve.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | dependabot: 3 | if: ${{ github.actor == 'dependabot[bot]' }} 4 | runs-on: ubuntu-latest 5 | steps: 6 | - id: metadata 7 | name: Dependabot metadata 8 | uses: dependabot/fetch-metadata@v2.4.0 9 | with: 10 | github-token: '${{ secrets.GITHUB_TOKEN }}' 11 | - env: 12 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 13 | PR_URL: ${{github.event.pull_request.html_url}} 14 | name: Approve a PR 15 | run: gh pr review --approve "$PR_URL" 16 | name: Dependabot auto-approve 17 | on: pull_request_target 18 | 19 | permissions: 20 | pull-requests: write 21 | -------------------------------------------------------------------------------- /systems/frontend/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000", 3 | "chromeWebSecurity": false, 4 | "component": { 5 | "componentFolder": "src", 6 | "testFiles": "**/*.spec.{tsx,ts}" 7 | }, 8 | "defaultCommandTimeout": 60000, 9 | "env": { 10 | "DISABLE_ESLINT_PLUGIN": "true", 11 | "NODE_ENV": "test", 12 | "coverage": false 13 | }, 14 | "pageLoadTimeout": 300000, 15 | "projectId": "code-test", 16 | "retries": { 17 | "openMode": 0, 18 | "runMode": 3 19 | }, 20 | "screenshotOnRunFailure": false, 21 | "testFiles": "**/*.spec.js", 22 | "video": false, 23 | "viewportHeight": 800, 24 | "viewportWidth": 1280 25 | } 26 | -------------------------------------------------------------------------------- /systems/backend/src/game-gallery/dto/get-game-list.args.ts: -------------------------------------------------------------------------------- 1 | import { ArgsType, Field, ID, Int } from '@nestjs/graphql'; 2 | import { IsOptional, Max, Min } from 'class-validator'; 3 | 4 | @ArgsType() 5 | export class GetGameListArgs { 6 | @Field({ nullable: true }) 7 | @IsOptional() 8 | name?: string; 9 | 10 | @Field({ nullable: true }) 11 | @IsOptional() 12 | platform?: string; 13 | 14 | @Field(() => ID, { nullable: true }) 15 | @IsOptional() 16 | userId?: string; 17 | 18 | @Field(() => Int) 19 | @Min(0) 20 | @IsOptional() 21 | offset = 0; 22 | 23 | @Field(() => Int) 24 | @Min(0) 25 | @Max(100) 26 | @IsOptional() 27 | limit = 10; 28 | } 29 | -------------------------------------------------------------------------------- /systems/frontend/src/GlobalContext.provider.tsx: -------------------------------------------------------------------------------- 1 | import { LocalizationProvider } from '@mui/lab'; 2 | import AdapterLuxon from '@mui/lab/AdapterLuxon'; 3 | import type { PropsWithChildren } from 'react'; 4 | 5 | import ApolloClientProvider from './ApolloClient.provider'; 6 | import ThemeProvider from './Theme.provider'; 7 | 8 | export default function GlobalContextProvider({ 9 | children, 10 | }: PropsWithChildren) { 11 | return ( 12 | 13 | 14 | 15 | {children} 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /docs/adr/0002-ssr-or-csr.md: -------------------------------------------------------------------------------- 1 | # Frontend SSR or CSR ? 2 | 3 | - Status: accepted 4 | - Date: 2022-03-22 5 | 6 | ## Context and Problem Statement 7 | 8 | I'm going to implement frontend page that show game gallery feature will include below 9 | 10 | - List all of my game library 11 | - Add new game to my library 12 | - Add box art to game 13 | - Read game detail 14 | 15 | ## Decision Drivers 16 | 17 | - SEO 18 | - Will content facing to anonymous user? 19 | - Deployment complexity 20 | 21 | ## Considered Options 22 | 23 | - Server side rendering 24 | - Client side rendering 25 | 26 | ## Decision Outcome 27 | 28 | Client side rendering without cross-origin 29 | because it would much easily setup and deploy -------------------------------------------------------------------------------- /systems/backend/src/main.lambda.ts: -------------------------------------------------------------------------------- 1 | import serverlessExpress from '@vendia/serverless-express'; 2 | import type { Callback, Context, Handler } from 'aws-lambda'; 3 | 4 | import { bootstrap } from './bootstrap'; 5 | 6 | let server: Handler; 7 | 8 | async function createServerlessExpress() { 9 | const app = await bootstrap(); 10 | await app.init(); 11 | const expressApp = app.getHttpAdapter().getInstance(); 12 | return serverlessExpress({ app: expressApp }); 13 | } 14 | 15 | export async function handler( 16 | event: any, 17 | context: Context, 18 | callback: Callback, 19 | ) { 20 | server = server ?? (await createServerlessExpress()); 21 | return server(event, context, callback); 22 | } 23 | -------------------------------------------------------------------------------- /systems/frontend/src/ApolloClient.provider.tsx: -------------------------------------------------------------------------------- 1 | import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client'; 2 | import { createUploadLink } from 'apollo-upload-client'; 3 | import type { PropsWithChildren } from 'react'; 4 | 5 | import getEnv from './get-env'; 6 | 7 | const cache = new InMemoryCache(); 8 | 9 | export default function ApolloClientProvider({ 10 | children, 11 | }: PropsWithChildren) { 12 | const uri = `${getEnv('REACT_APP_BACKEND_HOST')}/graphql`; 13 | const client = new ApolloClient({ 14 | cache, 15 | link: createUploadLink({ 16 | uri: uri, 17 | }), 18 | }); 19 | return {children}; 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | dependabot: 3 | if: ${{ github.actor == 'dependabot[bot]' }} 4 | runs-on: ubuntu-latest 5 | steps: 6 | - id: metadata 7 | name: Dependabot metadata 8 | uses: dependabot/fetch-metadata@v2.4.0 9 | with: 10 | github-token: '${{ secrets.GITHUB_TOKEN }}' 11 | - env: 12 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 13 | PR_URL: ${{github.event.pull_request.html_url}} 14 | name: Enable auto-merge for Dependabot PRs 15 | run: gh pr merge --auto --merge "$PR_URL" 16 | name: Dependabot auto-merge 17 | on: pull_request_target 18 | 19 | permissions: 20 | contents: write 21 | pull-requests: write 22 | -------------------------------------------------------------------------------- /systems/backend/src/error-hanlding/not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | 3 | import { 4 | ExceptionPayload, 5 | exceptionPayloadToResponse, 6 | } from './exception-payload'; 7 | 8 | export class NotFoundException extends HttpException { 9 | debugDetails?: Record | undefined; // Only visible on access log 10 | 11 | constructor(response: ExceptionPayload) { 12 | const { code, debugDetails, errors, meta } = response; 13 | super( 14 | exceptionPayloadToResponse({ 15 | code, 16 | errors, 17 | meta, 18 | }), 19 | HttpStatus.NOT_FOUND, 20 | ); 21 | this.debugDetails = debugDetails; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /systems/backend/src/error-hanlding/bad-request.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | 3 | import { 4 | ExceptionPayload, 5 | exceptionPayloadToResponse, 6 | } from './exception-payload'; 7 | 8 | export class BadRequestException extends HttpException { 9 | debugDetails?: Record | undefined; // Only visible on access log 10 | 11 | constructor(response: ExceptionPayload) { 12 | const { code, debugDetails, errors, meta } = response; 13 | super( 14 | exceptionPayloadToResponse({ 15 | code, 16 | errors, 17 | meta, 18 | }), 19 | HttpStatus.BAD_REQUEST, 20 | ); 21 | this.debugDetails = debugDetails; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /docs/adr/0007-deployment/README.md: -------------------------------------------------------------------------------- 1 | # Deployment 2 | 3 | - Status: accept 4 | 5 | ## Context and Problem Statement 6 | 7 | I want to find a place can host system in that repo. 8 | 9 | Which need include 10 | 11 | - CDN for frontend static asset 12 | - RDS for Database access 13 | - HTTP Server fo backend 14 | 15 | ## Decision Drivers 16 | 17 | - Pricing model, as it is side project , 18 | the pricing modal by traffic usage would be best 19 | - Setup complexity 20 | 21 | ## Considered Options 22 | 23 | - AWS RDS Aurora + Lambda + CloudFront + S3 24 | - GCP Cloud SQL + Cloud Run + Cloud Storage 25 | 26 | ## Decision Outcome 27 | 28 | - AWS because all option is serverless pricing modal. 29 | ![Deployment](./deploymnet.png) 30 | -------------------------------------------------------------------------------- /systems/backend/src/error-hanlding/internal-server-error.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | 3 | import { 4 | ExceptionPayload, 5 | exceptionPayloadToResponse, 6 | } from './exception-payload'; 7 | 8 | export class InternalServerErrorException extends HttpException { 9 | debugDetails?: Record | undefined; // Only visible on access log 10 | 11 | constructor(response: ExceptionPayload) { 12 | const { code, debugDetails, errors, meta } = response; 13 | super( 14 | exceptionPayloadToResponse({ 15 | code, 16 | errors, 17 | meta, 18 | }), 19 | HttpStatus.INTERNAL_SERVER_ERROR, 20 | ); 21 | this.debugDetails = debugDetails; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /systems/backend/src/error-hanlding/exception-payload.ts: -------------------------------------------------------------------------------- 1 | import type { ErrorCode } from './error-code.constant'; 2 | 3 | export interface ExceptionPayload { 4 | code: ErrorCode; 5 | debugDetails?: Record; 6 | errors: { code?: ErrorCode; detail?: string; title: string }[]; 7 | meta?: Record; 8 | } 9 | 10 | export function exceptionPayloadToResponse(payload: ExceptionPayload) { 11 | const isErrorsEmpty = (payload.errors?.length ?? 0) === 0; 12 | return { 13 | errors: isErrorsEmpty 14 | ? [{ code: payload.code }] 15 | : payload.errors.map(({ detail, title }) => ({ 16 | code: payload.code, 17 | detail, 18 | title, 19 | })), 20 | meta: payload.meta, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /systems/backend/src/error-hanlding/unauthorized.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | 3 | import type { ExceptionPayload } from './exception-payload'; 4 | import { exceptionPayloadToResponse } from './exception-payload'; 5 | 6 | export class UnauthorizedException extends HttpException { 7 | debugDetails?: Record | undefined; // Only visible on access log 8 | 9 | constructor(response: ExceptionPayload) { 10 | const { code, debugDetails, errors, meta } = response; 11 | super( 12 | exceptionPayloadToResponse({ 13 | code, 14 | errors, 15 | meta, 16 | }), 17 | HttpStatus.UNAUTHORIZED, 18 | ); 19 | this.debugDetails = debugDetails; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /systems/frontend/cypress/support/index.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /docs/adr/0008-file-upload.md: -------------------------------------------------------------------------------- 1 | # File upload 2 | 3 | - Status: accept 4 | 5 | ## Context and Problem Statement 6 | 7 | I want to upload file to S3 bucket by API. 8 | 9 | ## Decision Drivers 10 | 11 | - Support AWS lambda 12 | - Easily to set up 13 | 14 | ## Considered Options 15 | 16 | - pre-signed URL for upload s3 17 | - Adjust graphql-upload to support upload file in lambda 18 | - REST endpoint for upload file 19 | 20 | ## Decision Outcome 21 | 22 | - pre-signed URL for upload s3 23 | 24 | Ref: 25 | 26 | [Open issue on graphql-upload](https://github.com/jaydenseric/graphql-upload/issues/155) 27 | 28 | [Open issue on express multer](https://github.com/expressjs/multer/issues/770) 29 | 30 | [AWS serverless upload file](https://www.youtube.com/watch?v=mw_-0iCVpUc) 31 | -------------------------------------------------------------------------------- /.github/workflows/test-backend.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | test: 3 | runs-on: ubuntu-latest 4 | steps: 5 | - uses: actions/checkout@v6 6 | - uses: actions/setup-node@v6.1.0 7 | with: 8 | cache: npm 9 | cache-dependency-path: '**/package-lock.json' 10 | node-version-file: .nvmrc 11 | - run: bash ./scripts/setup.sh 12 | - run: npx lerna exec --stream --concurrency 1 --scope=backend -- bash scripts/ci/setup.sh 13 | - run: npx lerna exec --stream --concurrency 1 --scope=backend -- bash scripts/ci/test.sh 14 | name: Test systems/backend 15 | 16 | on: 17 | pull_request: 18 | paths: 19 | - 'systems/backend/**' 20 | push: 21 | branches: 22 | - development 23 | - master 24 | paths: 25 | - 'systems/backend/**' 26 | -------------------------------------------------------------------------------- /systems/backend/docker-compose.yml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | db: 4 | environment: 5 | - POSTGRES_USER=${USER} 6 | - POSTGRES_PASSWORD=dev 7 | image: postgres:13-alpine 8 | ports: 9 | - '5432:5432' 10 | volumes: 11 | - source: ./scripts/docker/setup-local-postgres.sql 12 | target: /docker-entrypoint-initdb.d/setup-local-postgres.sql 13 | type: bind 14 | s3: 15 | environment: 16 | - LOCALSTACK_SERVICES=s3 17 | - AWS_ACCESS_KEY_ID=test 18 | - AWS_SECRET_ACCESS_KEY=test 19 | image: localstack/localstack 20 | ports: 21 | - '4566:4566' 22 | - '4571:4571' 23 | volumes: 24 | - source: ./scripts/docker/setup-local-s3.sh 25 | target: /docker-entrypoint-initaws.d/setup-local-s3.sh 26 | type: bind 27 | version: '3.7' -------------------------------------------------------------------------------- /.github/workflows/test-frontend.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | test: 3 | runs-on: ubuntu-latest 4 | steps: 5 | - uses: actions/checkout@v6 6 | - uses: actions/setup-node@v6.1.0 7 | with: 8 | cache: npm 9 | cache-dependency-path: '**/package-lock.json' 10 | node-version-file: .nvmrc 11 | - run: bash ./scripts/setup.sh 12 | - run: npx lerna exec --stream --concurrency 1 --scope='{backend,frontend}' -- bash scripts/ci/setup.sh 13 | - run: npx lerna exec --stream --concurrency 1 --scope=frontend -- bash scripts/ci/test.sh 14 | name: Test systems/frontend 15 | 16 | on: 17 | pull_request: 18 | paths: 19 | - 'systems/frontend/**' 20 | push: 21 | branches: 22 | - development 23 | - master 24 | paths: 25 | - 'systems/frontend/**' 26 | -------------------------------------------------------------------------------- /.github/workflows/test-infrastructure.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | test: 3 | runs-on: ubuntu-latest 4 | steps: 5 | - uses: actions/checkout@v6 6 | - uses: actions/setup-node@v6.1.0 7 | with: 8 | cache: npm 9 | cache-dependency-path: '**/package-lock.json' 10 | node-version-file: .nvmrc 11 | - run: bash ./scripts/setup.sh 12 | - run: npx lerna exec --stream --concurrency 1 --scope=infrastructure -- bash scripts/ci/setup.sh 13 | - run: npx lerna exec --stream --concurrency 1 --scope=infrastructure -- bash scripts/ci/test.sh 14 | name: Test systems/infrastructure 15 | 16 | on: 17 | pull_request: 18 | paths: 19 | - 'systems/infrastructure/**' 20 | push: 21 | branches: 22 | - development 23 | - master 24 | paths: 25 | - 'systems/infrastructure/**' 26 | -------------------------------------------------------------------------------- /systems/backend/Dockerfile.lambda: -------------------------------------------------------------------------------- 1 | # Builder Stage 2 | FROM public.ecr.aws/lambda/nodejs:14 AS builder 3 | 4 | WORKDIR ${LAMBDA_TASK_ROOT} 5 | COPY ./ ${LAMBDA_TASK_ROOT} 6 | 7 | RUN npm ci --ignore-scripts && \ 8 | npm run build && \ 9 | npm run build:lambda 10 | 11 | # Run stage 12 | FROM public.ecr.aws/lambda/nodejs:14 13 | WORKDIR ${LAMBDA_TASK_ROOT} 14 | 15 | COPY --from=builder ${LAMBDA_TASK_ROOT}/dist/main.lambda.js \ 16 | ${LAMBDA_TASK_ROOT}/dist/ 17 | 18 | COPY --from=builder ${LAMBDA_TASK_ROOT}/dist/migrations/ \ 19 | ${LAMBDA_TASK_ROOT}/dist/migrations/ 20 | 21 | COPY --from=builder ${LAMBDA_TASK_ROOT}/nest-cli.json \ 22 | ${LAMBDA_TASK_ROOT}/nest-cli.lambda.json \ 23 | ${LAMBDA_TASK_ROOT}/main-lambda.js \ 24 | ${LAMBDA_TASK_ROOT}/package.json \ 25 | ${LAMBDA_TASK_ROOT}/package-lock.json \ 26 | ${LAMBDA_TASK_ROOT}/ 27 | 28 | RUN npm ci --ignore-scripts --production 29 | -------------------------------------------------------------------------------- /.github/workflows/deploy-frontend.yml: -------------------------------------------------------------------------------- 1 | env: 2 | API_HOST: https://qu3k5lklb8.execute-api.eu-west-2.amazonaws.com 3 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 4 | AWS_DEFAULT_REGION: eu-west-2 5 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 6 | S3_BUCKET: code-test-dev-bucket-1cdcee7 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v6 12 | - uses: actions/setup-node@v6.1.0 13 | with: 14 | cache: npm 15 | cache-dependency-path: '**/package-lock.json' 16 | node-version-file: .nvmrc 17 | - run: bash ./scripts/setup.sh 18 | - run: npx lerna exec --stream --concurrency 1 --scope=frontend -- bash 19 | scripts/ci/deploy.sh 20 | name: Deploy systems/frontend 21 | on: 22 | push: 23 | branches: 24 | - master 25 | paths: 26 | - systems/frontend/** 27 | -------------------------------------------------------------------------------- /systems/backend/src/test-helpers/jest/e2e-global-setup.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | ('use strict'); 3 | 4 | const typeorm = require('typeorm'); 5 | 6 | const dotenv = require('dotenv'); 7 | 8 | const { createConnection } = typeorm; 9 | 10 | dotenv.config({ path: '.env.test' }); 11 | 12 | module.exports = async function globalSetup() { 13 | const conn = await createConnection({ 14 | type: 'postgres', 15 | url: process.env['DATABASE_CONNECTION_URL'], 16 | }); 17 | const records = await conn.query(`SELECT schema_name 18 | FROM information_schema.schemata 19 | WHERE "schema_name" LIKE 'e2e_%';`); 20 | const schemaNames = records.map((record) => record.schema_name); 21 | await Promise.all( 22 | schemaNames 23 | .map((schemaName) => `DROP SCHEMA IF EXISTS "${schemaName}" CASCADE;`) 24 | .map((statement) => conn.query(statement)), 25 | ); 26 | await conn.close(); 27 | }; 28 | -------------------------------------------------------------------------------- /systems/backend/src/game-gallery/models/game.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | UpdateDateColumn, 7 | } from 'typeorm'; 8 | 9 | @Entity({ name: 'game' }) 10 | export class GameEntity { 11 | @PrimaryGeneratedColumn('uuid') 12 | declare id: string; 13 | 14 | @Column() 15 | platform!: string; 16 | 17 | @Column() 18 | publisher!: string; 19 | 20 | @Column() 21 | numberOfPlayers!: number; 22 | 23 | @Column() 24 | userId!: string; 25 | 26 | @Column() 27 | name!: string; 28 | 29 | @Column() 30 | genre!: string; 31 | 32 | @Column({ type: 'timestamp without time zone' }) 33 | releaseDate!: Date; 34 | 35 | @Column({ 36 | nullable: true, 37 | }) 38 | boxArtImageUrl?: string; 39 | 40 | @UpdateDateColumn() 41 | declare updatedAt: Date; 42 | 43 | @CreateDateColumn() 44 | declare createdAt: Date; 45 | } 46 | -------------------------------------------------------------------------------- /docs/adr/0006-express-or-nestjs.md: -------------------------------------------------------------------------------- 1 | # Express Or NestJs 2 | 3 | - Status: accepted 4 | 5 | ## Context and Problem Statement 6 | 7 | I need library for writing HTTP API 8 | 9 | ## Decision Drivers 10 | 11 | - Fast to setup 12 | - Easily to test 13 | - Production ready without spend so much time on setup 14 | 15 | ## Considered Options 16 | 17 | - NestJS 18 | 19 | Framework inspired from Spring and Angular , 20 | after setup correctly it would easily mock-out smaller part of system in test 21 | and a lot of manually job like swagger doc / validation schema can auto generate 22 | 23 | - Express 24 | 25 | Production battled framework but need a lot of custom setup 26 | because of unopinion nature 27 | 28 | - Koa 29 | 30 | Similar to express but have two-way middleware 31 | 32 | ## Decision Outcome 33 | 34 | NestJs because i already have ready to run template and learn for how to deal with 35 | that 36 | -------------------------------------------------------------------------------- /docs/adr/0001-game-gallery-database-table-structure.md: -------------------------------------------------------------------------------- 1 | # Database structure for code 2 | 3 | - Status: accepted 4 | - Date: 2022-03-22 5 | 6 | ## Context and Problem Statement 7 | 8 | I want design database table for holding game library of user. 9 | 10 | ## Decision Drivers 11 | 12 | - Time to development 13 | - Flexible on extends 14 | - Actual requirement 15 | 16 | ## Considered Options 17 | 18 | - have User, UserGames, Game three table 19 | - place userId on Game table 20 | 21 | ## Decision Outcome 22 | 23 | - place userId on Game table 24 | 25 | because I only have limited time and 26 | migration afterward seem not impossible 27 | 28 | ```mermaid 29 | erDiagram 30 | game { 31 | uuid id 32 | string platform 33 | int number_of_players 34 | string user_id 35 | string name 36 | string genre 37 | date release_date 38 | string box_art_image_url 39 | } 40 | ``` 41 | -------------------------------------------------------------------------------- /systems/frontend/src/Layout/psn_icon.svg: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /systems/backend/src/test-helpers/seeder/seeder.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | 5 | import { GameEntity } from '../../game-gallery/models/game.entity'; 6 | 7 | @Injectable() 8 | export class SeederService { 9 | constructor( 10 | @InjectRepository(GameEntity) 11 | private gameRepository: Repository, 12 | ) {} 13 | 14 | async create(data: any) { 15 | if (data.items) { 16 | return await this.gameRepository.save(data.items); 17 | } 18 | return await this.gameRepository.save(data); 19 | } 20 | 21 | async fineMany(conditions: any) { 22 | const records = await this.gameRepository.find({ where: conditions }); 23 | return records; 24 | } 25 | 26 | async fineOne(id: string) { 27 | const record = await this.gameRepository.findOne(id); 28 | return record; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /systems/backend/src/common/date.sclar.ts: -------------------------------------------------------------------------------- 1 | import { CustomScalar, Scalar } from '@nestjs/graphql'; 2 | import { Kind, ValueNode } from 'graphql'; 3 | import { DateTime } from 'luxon'; 4 | 5 | @Scalar('Date', () => Date) 6 | export class DateScalar implements CustomScalar { 7 | description = 'Date custom scalar type'; 8 | 9 | parseValue(value: unknown): Date { 10 | return new Date(value as number); // value from the client 11 | } 12 | 13 | serialize(value: unknown): string { 14 | return DateTime.fromJSDate(value as Date).toISODate(); // value sent to the client 15 | } 16 | 17 | parseLiteral(ast: ValueNode): Date { 18 | if (ast.kind === Kind.STRING) { 19 | return DateTime.fromISO(ast.value as string).toJSDate(); 20 | } 21 | // @ts-except-error default return null from NestJS Example 22 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 23 | // @ts-ignore 24 | return null; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /systems/backend/src/game-gallery/dto/add-game-to-library.args.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from '@nestjs/graphql'; 2 | import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; 3 | 4 | @InputType() 5 | export class AddGameToLibraryArgs { 6 | @Field({ nullable: false }) 7 | @IsNotEmpty() 8 | userId!: string; 9 | 10 | @Field({ nullable: false }) 11 | @IsNotEmpty() 12 | name!: string; 13 | 14 | @Field({ nullable: false }) 15 | @IsNotEmpty() 16 | numberOfPlayers!: number; 17 | 18 | @Field({ nullable: false }) 19 | @IsNotEmpty() 20 | platform!: string; 21 | 22 | @Field({ nullable: false }) 23 | @IsNotEmpty() 24 | publisher!: string; 25 | 26 | @Field({ nullable: false }) 27 | @IsNotEmpty() 28 | genre!: string; 29 | 30 | @Field({ nullable: false }) 31 | @IsNotEmpty() 32 | releaseDate!: Date; 33 | 34 | @Field({ nullable: true }) 35 | @IsString() 36 | @IsOptional() 37 | boxArtImageUrl?: string; 38 | } 39 | -------------------------------------------------------------------------------- /systems/backend/src/test-helpers/expect-response-code.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from 'supertest'; 2 | 3 | function jsonParse(val: string) { 4 | try { 5 | return JSON.parse(val); 6 | } catch (e) { 7 | return val; 8 | } 9 | } 10 | 11 | export function expectResponseCode(params: { 12 | expectedStatusCode: number; 13 | message?: string; 14 | }) { 15 | const { expectedStatusCode, message = 'Unexpected response status code' } = 16 | params; 17 | return (res: Response & { request: Request }) => { 18 | if (res.status !== expectedStatusCode) { 19 | throw new Error( 20 | ` 21 | ${message} ${res.status} 22 | ${res.request.method} ${res.request.url} 23 | requestBody 24 | ${ 25 | // @ts-expect-error type error from supertest 26 | JSON.stringify(jsonParse(res.request._data), null, 4) 27 | } 28 | body 29 | ${JSON.stringify(jsonParse(res.text), null, 4)}`, 30 | ); 31 | } 32 | return true; 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "", 3 | "dependencies": { 4 | "lerna": "9.0.3" 5 | }, 6 | "description": "", 7 | "devDependencies": { 8 | "@busybox/eslint-config": "5.10.0", 9 | "@busybox/prettier-config": "2023.7.14", 10 | "@commitlint/cli": "20.2.0", 11 | "@commitlint/config-lerna-scopes": "20.2.0", 12 | "commitlint": "20.2.0", 13 | "eslint": "8.16.0", 14 | "husky": "8.0.0", 15 | "is-ci": "4.1.0", 16 | "lint-staged": "16.2.7", 17 | "markdownlint-cli2": "0.20.0", 18 | "mrm": "4.1.22", 19 | "prettier": "2.6.2" 20 | }, 21 | "engines": { 22 | "node": ">=14", 23 | "yarn": "Use npm" 24 | }, 25 | "license": "MIT", 26 | "name": "app", 27 | "private": true, 28 | "repository": "git@github.com/davidNHK/project-bootstrap", 29 | "scripts": { 30 | "prepare": "is-ci || husky install", 31 | "prettier": "prettier", 32 | "start:dev": "lerna run --parallel start:dev" 33 | }, 34 | "version": "0.0.1" 35 | } 36 | -------------------------------------------------------------------------------- /docs/adr/0003-page-ux-design/README.md: -------------------------------------------------------------------------------- 1 | # Page UX design 2 | 3 | - Status: [proposed | rejected | accepted | deprecated | … | superseded] 4 | - Date: 2022-03-22 5 | 6 | ## Context and Problem Statement 7 | 8 | I go to implement the feature on frontend and need some research 9 | on how people in world doing. 10 | 11 | ## Decision Drivers 12 | 13 | - Implementation complexity 14 | - Make sense to user 15 | 16 | ## Considered Options 17 | 18 | - [PS Store](https://library.playstation.com/recently-purchased) 19 | 20 | ![PSN screen short](./psn.png) 21 | 22 | For XBox and Nintendo I don't have account to visit bought game list. 23 | so let check the console game list here. 24 | 25 | - [XBox](https://www.xbox.com/en-gb/xbox-game-pass/games?xr=shellnav) 26 | 27 | ![XBox game pass store](./xbox-game-pass-game-store.png) 28 | 29 | - [Nintendo](https://store.nintendo.co.uk/en_gb/games/view-all-games/) 30 | 31 | ![Nintendo game store](./nitendo.png) 32 | 33 | ## Decision Outcome 34 | 35 | Because PSN is much clean and easily to implement, let go on that 36 | 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Code Challenge 2 | 3 | ![Working screenshot](./docs/working-screenshot.png) 4 | 5 | ![Monthly Budget reference](./docs/aws-usd-budget.png) 6 | 7 | The easy way to review would be following [Development Section](#development) 8 | and read the [Code Review Section](#code-review) 9 | 10 | ## Code Review 11 | 12 | [Endpoint exposed](./systems/backend/schema.graphql) 13 | 14 | [Frontend code related to feature](./systems/frontend/src/GameLibraryPage) 15 | 16 | [Backend code related to feature](./systems/backend/src/game-gallery) 17 | 18 | [Infrastructure setup](./systems/infrastructure/src/index.ts) 19 | 20 | [Architecture decision record](./docs/adr) 21 | P.S. some of ADR document I circle back after finish coding, so it may out of order. 22 | 23 | ## Development 24 | 25 | ```sh 26 | npm install 27 | npx lerna bootstrap 28 | npx lerna exec --stream \ 29 | --scope '{backend,frontend}' -- bash scripts/dev-setup.sh 30 | npx lerna exec --stream \ 31 | --scope '{backend,frontend}' -- bash scripts/dev-server.sh 32 | 33 | Open http://localhost:3000 for dev 34 | ``` 35 | -------------------------------------------------------------------------------- /systems/backend/src/game-gallery/models/game.model.ts: -------------------------------------------------------------------------------- 1 | import { Field, ID, ObjectType } from '@nestjs/graphql'; 2 | 3 | @ObjectType() 4 | export class Game { 5 | @Field(() => ID) 6 | id!: string; 7 | 8 | @Field() 9 | numberOfPlayers!: number; 10 | 11 | @Field() 12 | platform!: string; 13 | 14 | @Field() 15 | publisher!: string; 16 | 17 | @Field() 18 | name!: string; 19 | 20 | @Field() 21 | genre!: string; 22 | 23 | @Field() 24 | releaseDate!: Date; 25 | 26 | @Field({ 27 | nullable: true, 28 | }) 29 | boxArtImageUrl?: string; 30 | 31 | @Field() 32 | updatedAt!: Date; 33 | 34 | @Field() 35 | createdAt!: Date; 36 | } 37 | 38 | @ObjectType() 39 | class GameNode { 40 | @Field() 41 | node!: Game; 42 | } 43 | 44 | @ObjectType() 45 | class PageInfo { 46 | @Field() 47 | hasNextPage!: boolean; 48 | } 49 | 50 | @ObjectType() 51 | export class GameList { 52 | @Field() 53 | totalCount!: number; 54 | 55 | @Field(() => [GameNode]) 56 | edges!: GameNode[]; 57 | 58 | @Field(() => PageInfo) 59 | pageInfo!: PageInfo; 60 | } 61 | -------------------------------------------------------------------------------- /systems/infrastructure/src/aws/api-gateway.ts: -------------------------------------------------------------------------------- 1 | import * as aws from '@pulumi/aws'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import kebabcase from 'lodash.kebabcase'; 4 | 5 | export function createAPIGateWay( 6 | lambda: aws.lambda.Function, 7 | cloudFrontDistribution: aws.cloudfront.Distribution, 8 | ) { 9 | const namePrefix = kebabcase(pulumi.getStack()); 10 | new aws.lambda.Permission(`${namePrefix}-lambda-api-gateway-permission`, { 11 | action: 'lambda:InvokeFunction', 12 | function: lambda, 13 | principal: 'apigateway.amazonaws.com', 14 | }); 15 | 16 | // Set up the API Gateway 17 | const apigw = new aws.apigatewayv2.Api('httpApiGateway', { 18 | corsConfiguration: { 19 | allowCredentials: true, 20 | allowHeaders: ['Content-Type'], 21 | allowMethods: ['*'], 22 | allowOrigins: [ 23 | pulumi.interpolate`https://${cloudFrontDistribution.domainName}`, 24 | ], 25 | }, 26 | protocolType: 'HTTP', 27 | routeKey: '$default', 28 | target: lambda.invokeArn, 29 | }); 30 | return { apigw }; 31 | } 32 | -------------------------------------------------------------------------------- /docs/adr/0004-graphql-or-rest.md: -------------------------------------------------------------------------------- 1 | # Endpoint style 2 | 3 | - Status: accepted 4 | 5 | ## Context and Problem Statement 6 | 7 | I need some RPC protocol for manipulate db record through web browser. 8 | 9 | task I need to do 10 | 11 | - Add game to user library 12 | - Upload file for game box art 13 | - Get all name with filter and pagination supported 14 | - Get game by id 15 | 16 | ## Decision Drivers 17 | 18 | - DX - do i easy to integrate and will boots my productivity? 19 | - do i family enough on such protocol, 20 | so I don't have to spend a lot of time on googling ? 21 | 22 | ## Considered Options 23 | 24 | - REST 25 | - Most family protocol and easily to implement 26 | - Product battle for very long time, it super stables 27 | - GraphQL 28 | - Out of box schema generation 29 | - Some basic validator already included 30 | - More auto complete feature because schema generation 31 | - Play together with React context can save a lot of time. 32 | - Less family protocol 33 | 34 | ## Decision Outcome 35 | 36 | - Graphql because project here is simple enough, the risk is acceptable 37 | -------------------------------------------------------------------------------- /systems/backend/src/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import type { NestExpressApplication } from '@nestjs/platform-express'; 4 | import helmet from 'helmet'; 5 | 6 | import { AppModule } from './app.module'; 7 | import { configuration } from './config/configuration'; 8 | import { NestLogger } from './logging/nest-logger'; 9 | 10 | export async function bootstrap() { 11 | const app = await NestFactory.create(AppModule, { 12 | bufferLogs: true, 13 | }); 14 | return setupApp(app); 15 | } 16 | 17 | export function setupApp(app: NestExpressApplication) { 18 | const config = app.get(ConfigService); 19 | const frontendOrigin = config.get('frontend.origin'); 20 | const logger = app.get(NestLogger); 21 | app.enableCors({ credentials: true, origin: [frontendOrigin] }); 22 | app.useLogger(logger); 23 | app.use(helmet({})); 24 | 25 | app.enableShutdownHooks(); 26 | 27 | logger.log({ 28 | config: configuration(), 29 | level: 'info', 30 | message: 'Starting server', 31 | }); 32 | return app; 33 | } 34 | -------------------------------------------------------------------------------- /systems/backend/scripts/ci/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/use/bin bash 2 | 3 | set -ex 4 | tag=${GITHUB_SHA:-testing} 5 | ecr_repo=${ECR_REPO} 6 | ecr_image_name=${ECR_IMAGE_NAME} 7 | lambda_function_arn=${LAMBDA_FUNCTION_ARN} 8 | lambda_function_latest_version_alias_name=${LAMBDA_FUNCTION_LATEST_VERSION_ALIAS_NAME} 9 | 10 | aws_region=${AWS_DEFAULT_REGION} 11 | aws ecr get-login-password --region "$aws_region" | docker login --username AWS --password-stdin "$ecr_repo" 12 | docker build -t backend:latest -t "backend:$tag" -f ./Dockerfile.lambda . 13 | docker tag backend:latest "$ecr_repo/$ecr_image_name:latest" 14 | docker tag "backend:$tag" "$ecr_repo/$ecr_image_name:$tag" 15 | docker push "$ecr_repo/$ecr_image_name:latest" 16 | docker push "$ecr_repo/$ecr_image_name:$tag" 17 | latest_version=$(aws lambda update-function-code \ 18 | --function-name "$lambda_function_arn" \ 19 | --image-uri "$ecr_repo/$ecr_image_name:$tag" \ 20 | --publish | jq -r '.Version') 21 | aws lambda update-alias \ 22 | --function-name "$lambda_function_arn" \ 23 | --function-version "$latest_version" \ 24 | --name "$lambda_function_latest_version_alias_name" 25 | -------------------------------------------------------------------------------- /systems/backend/src/health-check/health.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from '@jest/globals'; 2 | import { TerminusModule } from '@nestjs/terminus'; 3 | 4 | import { createRequestAgent } from '../test-helpers/create-request-agent'; 5 | import { expectResponseCode } from '../test-helpers/expect-response-code'; 6 | import { withNestServerContext } from '../test-helpers/nest-app-context'; 7 | import { HealthModule } from './health.module'; 8 | 9 | const appContext = withNestServerContext({ 10 | imports: [TerminusModule, HealthModule], 11 | }); 12 | describe('GET /healthz', () => { 13 | it('/healthz (GET)', async () => { 14 | const app = appContext.app; 15 | const { body } = await createRequestAgent(app.getHttpServer()) 16 | .get('/healthz') 17 | .expect(expectResponseCode({ expectedStatusCode: 200 })); 18 | expect(body).toStrictEqual({ 19 | details: { 20 | database: { 21 | status: 'up', 22 | }, 23 | }, 24 | error: {}, 25 | info: { 26 | database: { 27 | status: 'up', 28 | }, 29 | }, 30 | status: 'ok', 31 | }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /systems/backend/docker-compose-test.yml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | app: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile.lambda 7 | command: [main-lambda.handler] 8 | depends_on: 9 | - db 10 | - s3 11 | env_file: 12 | - .env.test 13 | environment: 14 | - DATABASE_CONNECTION_URL=postgres://${USER}:dev@db:5432/dev 15 | - AWS_ACCESS_KEY_ID=test 16 | - AWS_SECRET_ACCESS_KEY=test 17 | - APP_MODE=lambda 18 | - APP_ENV=test 19 | db: 20 | environment: 21 | - POSTGRES_USER=${USER} 22 | - POSTGRES_PASSWORD=dev 23 | image: postgres:13-alpine 24 | volumes: 25 | - source: ./scripts/docker/setup-local-postgres.sql 26 | target: /docker-entrypoint-initdb.d/setup-local-postgres.sql 27 | type: bind 28 | s3: 29 | environment: 30 | - LOCALSTACK_SERVICES=s3 31 | - AWS_ACCESS_KEY_ID=test 32 | - AWS_SECRET_ACCESS_KEY=test 33 | image: localstack/localstack 34 | volumes: 35 | - source: ./scripts/docker/setup-local-s3.sh 36 | target: /docker-entrypoint-initaws.d/setup-local-s3.sh 37 | type: bind 38 | version: '3.7' -------------------------------------------------------------------------------- /.github/workflows/deploy-backend.yml: -------------------------------------------------------------------------------- 1 | env: 2 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 3 | AWS_DEFAULT_REGION: eu-west-2 4 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 5 | ECR_IMAGE_NAME: code-test-dev-image-698bba4 6 | ECR_REPO: 139227058951.dkr.ecr.eu-west-2.amazonaws.com 7 | LAMBDA_FUNCTION_ARN: arn:aws:lambda:eu-west-2:139227058951:function:code-test-dev-lambda-ad5312d 8 | LAMBDA_FUNCTION_LATEST_VERSION_ALIAS_NAME: code-test-dev-latest-alias-8976075 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v6 14 | - uses: actions/setup-node@v6.1.0 15 | with: 16 | cache: npm 17 | cache-dependency-path: '**/package-lock.json' 18 | node-version-file: .nvmrc 19 | - run: bash ./scripts/setup.sh 20 | - run: npx lerna exec --stream --concurrency 1 --scope=backend -- bash 21 | scripts/ci/setup.sh 22 | - run: npx lerna exec --stream --concurrency 1 --scope=backend -- bash 23 | scripts/ci/deploy.sh 24 | name: Deploy systems/backend 25 | on: 26 | push: 27 | branches: 28 | - master 29 | paths: 30 | - systems/backend/** 31 | -------------------------------------------------------------------------------- /systems/backend/src/game-gallery/game.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; 2 | 3 | import { AddGameToLibraryArgs } from './dto/add-game-to-library.args'; 4 | import { GetGameArgs } from './dto/get-game.args'; 5 | import { GetGameListArgs } from './dto/get-game-list.args'; 6 | import { UploadGameBoxArtArgs } from './dto/upload-game-box-art.args'; 7 | import { GameService } from './game.service'; 8 | import { Game, GameList } from './models/game.model'; 9 | import { PrepareUpload } from './models/prepare-result.model'; 10 | 11 | @Resolver(() => Game) 12 | export class GameResolver { 13 | constructor(private gameService: GameService) {} 14 | 15 | @Query(() => GameList) 16 | async gameList(@Args() where: GetGameListArgs): Promise { 17 | return this.gameService.fineGamesList(where); 18 | } 19 | 20 | @Query(() => Game) 21 | async game(@Args() args: GetGameArgs) { 22 | return this.gameService.fineGame(args.id); 23 | } 24 | 25 | @Mutation(() => PrepareUpload) 26 | async prepareUploadGameBoxArt(@Args() args: UploadGameBoxArtArgs) { 27 | return await this.gameService.preSignUploadBoxArtUrl(args); 28 | } 29 | 30 | @Mutation(() => Game) 31 | async addGameToLibrary(@Args('data') args: AddGameToLibraryArgs) { 32 | return this.gameService.createGame(args); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /systems/infrastructure/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | # [1.3.0](https://github.com/davidNHK/sony-code-test/compare/v1.1.0...v1.3.0) (2022-04-02) 7 | 8 | 9 | ### Features 10 | 11 | * enable auto update github action workflow file ([c38fd3e](https://github.com/davidNHK/sony-code-test/commit/c38fd3e2f2447eabdc025b1511dcc139ee4d3e61)) 12 | 13 | 14 | 15 | 16 | 17 | # [1.2.0](https://github.com/davidNHK/sony-code-test/compare/v1.1.0...v1.2.0) (2022-03-28) 18 | 19 | **Note:** Version bump only for package infrastructure 20 | 21 | 22 | 23 | 24 | 25 | # [1.1.0](https://github.com/davidNHK/sony-code-test/compare/v1.0.4...v1.1.0) (2022-03-27) 26 | 27 | 28 | ### Features 29 | 30 | * enable lambda provisioned concurrency ([7340014](https://github.com/davidNHK/sony-code-test/commit/73400149f3cc29080c79a970c34cc5e86855c770)) 31 | 32 | 33 | 34 | 35 | 36 | ## [1.0.4](https://github.com/davidNHK/sony-code-test/compare/v1.0.2...v1.0.4) (2022-03-26) 37 | 38 | **Note:** Version bump only for package infrastructure 39 | 40 | 41 | 42 | 43 | 44 | ## [1.0.3](https://github.com/davidNHK/sony-code-test/compare/v1.0.2...v1.0.3) (2022-03-26) 45 | 46 | **Note:** Version bump only for package infrastructure 47 | -------------------------------------------------------------------------------- /systems/backend/schema.graphql: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------ 2 | # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) 3 | # ------------------------------------------------------ 4 | 5 | input AddGameToLibraryArgs { 6 | boxArtImageUrl: String 7 | genre: String! 8 | name: String! 9 | numberOfPlayers: Float! 10 | platform: String! 11 | publisher: String! 12 | releaseDate: Date! 13 | userId: String! 14 | } 15 | 16 | """Date custom scalar type""" 17 | scalar Date 18 | 19 | type Game { 20 | boxArtImageUrl: String 21 | createdAt: Date! 22 | genre: String! 23 | id: ID! 24 | name: String! 25 | numberOfPlayers: Float! 26 | platform: String! 27 | publisher: String! 28 | releaseDate: Date! 29 | updatedAt: Date! 30 | } 31 | 32 | type GameList { 33 | edges: [GameNode!]! 34 | pageInfo: PageInfo! 35 | totalCount: Float! 36 | } 37 | 38 | type GameNode { 39 | node: Game! 40 | } 41 | 42 | type Mutation { 43 | addGameToLibrary(data: AddGameToLibraryArgs!): Game! 44 | prepareUploadGameBoxArt(fileName: String!): PrepareUpload! 45 | } 46 | 47 | type PageInfo { 48 | hasNextPage: Boolean! 49 | } 50 | 51 | type PrepareUpload { 52 | id: ID! 53 | resultPublicUrl: String! 54 | uploadUrl: String! 55 | } 56 | 57 | type Query { 58 | game(id: ID!): Game! 59 | gameList(limit: Int = 10, name: String, offset: Int = 0, platform: String, userId: ID): GameList! 60 | } -------------------------------------------------------------------------------- /systems/backend/src/test-helpers/seeder/seeder.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | NotFoundException, 6 | Param, 7 | Post, 8 | Query, 9 | } from '@nestjs/common'; 10 | import { ConfigService } from '@nestjs/config'; 11 | 12 | import { AppEnvironment } from '../../config/config.constants'; 13 | import { SeederService } from './seeder.service'; 14 | 15 | @Controller('/test/seeder/game') 16 | export class SeederController { 17 | constructor( 18 | private seederService: SeederService, 19 | private config: ConfigService, 20 | ) {} 21 | 22 | private shouldExposeEndpoint() { 23 | return [AppEnvironment.DEV, AppEnvironment.TEST].includes( 24 | this.config.get('env')!, 25 | ); 26 | } 27 | 28 | @Get('/:id') 29 | async fineOne(@Param('id') id: string) { 30 | if (!this.shouldExposeEndpoint()) 31 | throw new NotFoundException('Route Not found'); 32 | return { data: await this.seederService.fineOne(id) }; 33 | } 34 | 35 | @Get('/') 36 | async fineMany(@Query() query: any) { 37 | if (!this.shouldExposeEndpoint()) 38 | throw new NotFoundException('Route Not found'); 39 | return { data: { items: await this.seederService.fineMany(query) } }; 40 | } 41 | 42 | @Post('/') 43 | async create(@Body() payload: any) { 44 | if (!this.shouldExposeEndpoint()) 45 | throw new NotFoundException('Route Not found'); 46 | return { data: await this.seederService.create(payload) }; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /systems/backend/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | # [1.3.0](https://github.com/davidNHK/sony-code-test/compare/v1.1.0...v1.3.0) (2022-04-02) 7 | 8 | 9 | ### Features 10 | 11 | * backend api always return http status 200 on graphql endpoint ([612c553](https://github.com/davidNHK/sony-code-test/commit/612c5534a195a0e1995d9d2ac5ff80bdd81e0796)) 12 | * use webpack on lambda function ([fd51681](https://github.com/davidNHK/sony-code-test/commit/fd5168191513d44cc2544564c154869336ebe013)) 13 | 14 | 15 | 16 | 17 | 18 | # [1.2.0](https://github.com/davidNHK/sony-code-test/compare/v1.1.0...v1.2.0) (2022-03-28) 19 | 20 | **Note:** Version bump only for package backend 21 | 22 | 23 | 24 | 25 | 26 | # [1.1.0](https://github.com/davidNHK/sony-code-test/compare/v1.0.4...v1.1.0) (2022-03-27) 27 | 28 | 29 | ### Features 30 | 31 | * enable lambda provisioned concurrency ([7340014](https://github.com/davidNHK/sony-code-test/commit/73400149f3cc29080c79a970c34cc5e86855c770)) 32 | 33 | 34 | 35 | 36 | 37 | ## [1.0.4](https://github.com/davidNHK/sony-code-test/compare/v1.0.2...v1.0.4) (2022-03-26) 38 | 39 | **Note:** Version bump only for package backend 40 | 41 | 42 | 43 | 44 | 45 | ## [1.0.3](https://github.com/davidNHK/sony-code-test/compare/v1.0.2...v1.0.3) (2022-03-26) 46 | 47 | **Note:** Version bump only for package backend 48 | -------------------------------------------------------------------------------- /systems/frontend/src/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import AppBar from '@mui/material/AppBar'; 2 | import Box from '@mui/material/Box'; 3 | import Paper from '@mui/material/Paper'; 4 | import Toolbar from '@mui/material/Toolbar'; 5 | import Typography from '@mui/material/Typography'; 6 | import type { PropsWithChildren } from 'react'; 7 | 8 | import PSNIcon from './psn_icon.svg'; 9 | import SonyIcon from './sony_logo.svg'; 10 | 11 | function Heading() { 12 | return ( 13 | 22 | 29 | 30 | ); 31 | } 32 | 33 | function NavBar() { 34 | return ( 35 | 41 | 42 | 43 | 44 | Game Library 45 | 46 | 47 | 48 | ); 49 | } 50 | 51 | export default function Layout({ children }: PropsWithChildren) { 52 | return ( 53 | <> 54 | 55 | 56 | {children} 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /systems/infrastructure/README.md: -------------------------------------------------------------------------------- 1 | # Infrastructure as code 2 | 3 | Include Setup AWS with 4 | * RDS 5 | * CloudFront 6 | * S3 7 | * Lambda 8 | * API Gateway 9 | * ECR 10 | 11 | ## Setup 12 | 13 | [Install pulumi CLI](https://www.pulumi.com/docs/get-started/install/) 14 | ```bash 15 | bash ./scripts/ci/setup.sh 16 | bash ./scripts/ci/deploy.sh 17 | ``` 18 | 19 | ## Verify deployment 20 | 21 | After execute `deploy.sh`, you will see the following output in console: 22 | 23 | ```text 24 | API_HOST https://l1kqpd5nqc.execute-api.eu-west-2.amazonaws.com 25 | CLOUDFRONT_URL https://d16m8sgb6n5atc.cloudfront.net 26 | DATABASE_HOST tf-20220326123106742800000006.cluster-cxksjykhpxmn.eu-west-2.rds.amazonaws.com 27 | DATABASE_PASSWORD [secret] 28 | ECR_IMAGE_NAME code-test-image-092187c 29 | ECR_REPO 139227058951.dkr.ecr.eu-west-2.amazonaws.com 30 | LAMBDA_FUNCTION_ARN arn:aws:lambda:eu-west-2:139227058951:function:code-test-lambda-9efb696 31 | S3_BUCKET code-test-bucket-7afec63 32 | ``` 33 | 34 | In case you missed, execute `pulumi stack output` to see it again. 35 | 36 | then you can verify the deployment by running the following steps: 37 | 38 | - Open `frontend/index.html` in a browser 39 | - Open `frontend/upload/demo.gif` in a browser 40 | 41 | ## Manual steps after deployment 42 | 43 | You need update environment setting in below files: 44 | 45 | [Frontend deploy workflow](/.github/workflows/deploy-frontend.yml) 46 | 47 | [Backend deploy workflow](/.github/workflows/deploy-backend.yml) -------------------------------------------------------------------------------- /systems/backend/src/logging/logger.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import type { Logform } from 'winston'; 4 | import { 5 | createLogger, 6 | format, 7 | Logger as WinstonLogger, 8 | transports, 9 | } from 'winston'; 10 | 11 | import { AppEnvironment } from '../config/config.constants'; 12 | import { Level } from './logging.constants'; 13 | 14 | @Injectable() 15 | export class Logger { 16 | private winstonLogger: WinstonLogger; 17 | 18 | constructor(private config: ConfigService) { 19 | const env = config.get('env'); 20 | const isDev = [AppEnvironment.DEV].includes(env); 21 | 22 | this.winstonLogger = createLogger({ 23 | format: format.combine( 24 | format.timestamp(), 25 | ...(isDev 26 | ? [ 27 | format((info: any) => { 28 | return info.context === 'GeneralLoggingInterceptor' 29 | ? false 30 | : info; 31 | })(), 32 | format.prettyPrint({ colorize: true, depth: 4 }), 33 | ] 34 | : [format.json()]), 35 | ), 36 | level: isDev ? Level.debug : Level.info, 37 | silent: [AppEnvironment.CI_TEST, AppEnvironment.TEST].includes(env), 38 | transports: [new transports.Console({})], 39 | }); 40 | } 41 | 42 | log( 43 | level: Level, 44 | message: string, 45 | infoObj: Omit, 46 | ) { 47 | this.winstonLogger.log(level, message, infoObj); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /systems/frontend/src/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /systems/infrastructure/src/aws/rds.ts: -------------------------------------------------------------------------------- 1 | import * as aws from '@pulumi/aws'; 2 | import type * as awsx from '@pulumi/awsx'; 3 | import * as pulumi from '@pulumi/pulumi'; 4 | import * as random from '@pulumi/random'; 5 | import camelcase from 'lodash.camelcase'; 6 | import kebabcase from 'lodash.kebabcase'; 7 | 8 | export async function createRDS(vpc: awsx.ec2.Vpc) { 9 | const rdsConfig = new pulumi.Config('rds'); 10 | const password = new random.RandomPassword('rds-password', { 11 | length: 16, 12 | special: false, 13 | }).result; 14 | const dbUser = rdsConfig.require('user'); 15 | const namePrefix = kebabcase(pulumi.getStack()); 16 | const subnetGroup = new aws.rds.SubnetGroup( 17 | `${namePrefix}-rds-subnet`, 18 | { 19 | subnetIds: vpc.privateSubnetIds, 20 | }, 21 | { dependsOn: vpc }, 22 | ); 23 | const database = new aws.rds.Cluster( 24 | `${namePrefix}-rds`, 25 | { 26 | availabilityZones: ['eu-west-2a', 'eu-west-2b', 'eu-west-2c'], 27 | databaseName: `${camelcase(namePrefix)}`, 28 | dbSubnetGroupName: subnetGroup.name, 29 | enableHttpEndpoint: true, 30 | engine: 'aurora-postgresql', 31 | engineMode: 'serverless', 32 | finalSnapshotIdentifier: undefined, 33 | masterPassword: password, 34 | masterUsername: dbUser, 35 | scalingConfiguration: { 36 | minCapacity: 2, 37 | secondsUntilAutoPause: 86400 - 1, 38 | }, 39 | skipFinalSnapshot: true, 40 | }, 41 | { 42 | dependsOn: [vpc, subnetGroup], 43 | }, 44 | ); 45 | return { database, password }; 46 | } 47 | -------------------------------------------------------------------------------- /systems/backend/src/logging/nest-logger.ts: -------------------------------------------------------------------------------- 1 | import { Inject, LoggerService } from '@nestjs/common'; 2 | import type { Logform } from 'winston'; 3 | 4 | import { Logger } from './logger'; 5 | import { Level } from './logging.constants'; 6 | 7 | function isInfoObj( 8 | infoObj: Logform.TransformableInfo | string, 9 | ): infoObj is Logform.TransformableInfo { 10 | return typeof infoObj !== 'string'; 11 | } 12 | 13 | export class NestLogger implements LoggerService { 14 | constructor(@Inject(Logger) private logger: Logger) {} 15 | 16 | private logMessage( 17 | level: Level, 18 | infoObj: Logform.TransformableInfo | string, 19 | context?: string, 20 | ) { 21 | const { message, ...restObj } = isInfoObj(infoObj) 22 | ? infoObj 23 | : { message: infoObj }; 24 | this.logger.log(level, message, { 25 | context, 26 | ...restObj, 27 | }); 28 | } 29 | 30 | debug(infoObj: Logform.TransformableInfo, context?: string): any { 31 | this.logMessage(Level.debug, infoObj, context); 32 | } 33 | 34 | error(infoObj: Logform.TransformableInfo, trace?: string, context?: string) { 35 | this.logMessage( 36 | Level.error, 37 | isInfoObj(infoObj) 38 | ? { ...infoObj, trace } 39 | : { level: Level.error, message: infoObj, trace }, 40 | context, 41 | ); 42 | } 43 | 44 | log(infoObj: Logform.TransformableInfo, context?: string) { 45 | this.logMessage(Level.info, infoObj, context); 46 | } 47 | 48 | warn(infoObj: Logform.TransformableInfo, context?: string) { 49 | this.logMessage(Level.warn, infoObj, context); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /systems/backend/src/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Module, Optional } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { 4 | TypeOrmModule, 5 | TypeOrmModuleOptions, 6 | TypeOrmOptionsFactory, 7 | } from '@nestjs/typeorm'; 8 | import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; 9 | 10 | import { AppEnvironment } from '../config/config.constants'; 11 | import { JestTestStateProvider } from '../test-helpers/jest/jest-test-state.provider'; 12 | 13 | @Injectable() 14 | class TypeOrmConfigService implements TypeOrmOptionsFactory { 15 | constructor( 16 | private config: ConfigService, 17 | @Optional() private testState?: JestTestStateProvider, 18 | ) {} 19 | 20 | createTypeOrmOptions(): TypeOrmModuleOptions { 21 | const { db } = this.testState?.testConfig ?? {}; 22 | const isTest = [AppEnvironment.TEST].includes(this.config.get('env')!); 23 | const { connectionURL, type } = this.config.get('database'); 24 | return { 25 | autoLoadEntities: true, 26 | migrations: ['dist/migrations/*'], 27 | migrationsRun: true, 28 | namingStrategy: new SnakeNamingStrategy() as any, 29 | type, 30 | url: connectionURL, 31 | ...(isTest && db?.schema 32 | ? { 33 | schema: db.schema, 34 | } 35 | : {}), 36 | }; 37 | } 38 | } 39 | 40 | @Module({}) 41 | export class DatabaseModule { 42 | static forRoot() { 43 | return TypeOrmModule.forRootAsync({ 44 | imports: [ConfigModule], 45 | useClass: TypeOrmConfigService, 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /systems/infrastructure/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@pulumi/aws": "7.14.0", 4 | "@pulumi/awsx": "3.1.0", 5 | "@pulumi/pulumi": "3.212.0", 6 | "@pulumi/random": "4.18.4", 7 | "@types/mocha": "10.0.10", 8 | "handlebars": "4.7.8", 9 | "lodash.camelcase": "4.3.0", 10 | "lodash.kebabcase": "4.1.1", 11 | "mocha": "11.7.5", 12 | "ts-node": "10.9.2", 13 | "yaml": "2.8.2" 14 | }, 15 | "devDependencies": { 16 | "@babel/cli": "7.28.3", 17 | "@babel/core": "7.28.5", 18 | "@babel/plugin-proposal-class-properties": "7.18.6", 19 | "@babel/plugin-proposal-decorators": "7.28.0", 20 | "@babel/preset-env": "7.28.5", 21 | "@babel/preset-typescript": "7.28.5", 22 | "@busybox/eslint-config": "5.10.0", 23 | "@busybox/tsconfig": "1.7.1", 24 | "@types/chai": "5.2.3", 25 | "@types/lodash.camelcase": "4.3.9", 26 | "@types/lodash.kebabcase": "4.1.9", 27 | "@types/node": "25.0.3", 28 | "babel-plugin-transform-typescript-metadata": "0.4.0", 29 | "chai": "6.2.1", 30 | "eslint": "8.16.0", 31 | "typescript": "4.7.2" 32 | }, 33 | "engines": { 34 | "node": ">=14", 35 | "yarn": "Use npm" 36 | }, 37 | "license": "MIT", 38 | "main": "bin/index.js", 39 | "name": "infrastructure", 40 | "private": true, 41 | "scripts": { 42 | "build": "npx babel --config-file ./.babelrc.esm.mjs --out-dir ./bin --extensions .ts --ignore ./src/aws/ecr/node_modules --copy-files --no-copy-ignored ./src", 43 | "eslint": "eslint --ext=json,ts,yml", 44 | "lint:ci": "npm run eslint .", 45 | "test": "npx mocha" 46 | }, 47 | "type": "module", 48 | "version": "1.3.0" 49 | } 50 | -------------------------------------------------------------------------------- /systems/backend/src/test-helpers/get-apollo-server.ts: -------------------------------------------------------------------------------- 1 | import type { INestApplication } from '@nestjs/common'; 2 | import type { DocumentNode, GraphQLError } from 'graphql'; 3 | import { print } from 'graphql/language/printer'; 4 | 5 | import { expectResponseCode } from './expect-response-code'; 6 | import { getRequestAgent } from './get-request-agent'; 7 | 8 | // The reason why I don't use getApolloServer from @nestjs/apollo is because 9 | // https://github.com/apollographql/apollo-server/issues/2277 10 | // this function can make req, res available on apollo context 11 | export function getApolloServer(app: INestApplication) { 12 | const requestAgent = getRequestAgent(app.getHttpServer()); 13 | return { 14 | async executeOperation({ 15 | http, 16 | query, 17 | variables, 18 | }: { 19 | http?: { 20 | headers?: Record; 21 | }; 22 | query: DocumentNode; 23 | variables?: Record; 24 | }): Promise<{ data: D; errors?: GraphQLError[] }> { 25 | const requestChain = requestAgent.post('/graphql'); 26 | Object.entries(http?.headers ?? {}).forEach(([key, value]) => { 27 | requestChain.set(key, value); 28 | }); 29 | const { body } = await requestChain 30 | .send({ query: print(query), variables }) 31 | .expect( 32 | expectResponseCode({ 33 | expectedStatusCode: 200, 34 | message: `GraphQL query should always return status code 200 35 | see https://www.apollographql.com/docs/apollo-server/data/errors/#returning-http-status-codes`, 36 | }), 37 | ); 38 | return body; 39 | }, 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /systems/infrastructure/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | 3 | import { createAPIGateWay } from './aws/api-gateway.js'; 4 | import { createCloudFront } from './aws/cloudfront.js'; 5 | import { createECRImage } from './aws/ecr/index.js'; 6 | import { createLambda } from './aws/lambda.js'; 7 | import { createRDS } from './aws/rds.js'; 8 | import { createS3Bucket, uploadTestIndexFile } from './aws/s3/index.js'; 9 | import { createVPC } from './aws/vpc.js'; 10 | 11 | const { vpc } = createVPC(); 12 | const { bucket } = createS3Bucket(); 13 | const { cloudFrontDistribution } = createCloudFront(bucket); 14 | const { database, password } = await createRDS(vpc); 15 | const { image } = createECRImage(); 16 | const { lambdaFunction, lambdaLatestVersionAlias } = await createLambda(image, { 17 | cloudFrontDistribution, 18 | rds: database, 19 | s3Bucket: bucket, 20 | vpc, 21 | }); 22 | const { apigw } = createAPIGateWay(lambdaFunction, cloudFrontDistribution); 23 | await uploadTestIndexFile(bucket, apigw); 24 | export const ECR_REPO = image.repository.repository.repositoryUrl.apply( 25 | url => url.split('/')[0], 26 | ); 27 | export const ECR_IMAGE_NAME = image.repository.repository.name; 28 | export const DATABASE_NAME = database.databaseName; 29 | export const DATABASE_HOST = database.endpoint; 30 | export const DATABASE_USER = database.masterUsername; 31 | export const DATABASE_PASSWORD = password; 32 | 33 | export const CLOUDFRONT_URL = pulumi.interpolate`https://${cloudFrontDistribution.domainName}`; 34 | export const API_HOST = apigw.apiEndpoint; 35 | export const LAMBDA_FUNCTION_ARN = lambdaFunction.arn; 36 | export const S3_BUCKET = bucket.bucket; 37 | export const LAMBDA_FUNCTION_LATEST_VERSION_ALIAS_ARN = 38 | lambdaLatestVersionAlias.name; 39 | -------------------------------------------------------------------------------- /systems/frontend/src/Theme.provider.tsx: -------------------------------------------------------------------------------- 1 | import './theme.css'; 2 | 3 | import type {} from '@mui/lab/themeAugmentation'; 4 | import { createTheme, responsiveFontSizes } from '@mui/material/styles'; 5 | import { 6 | experimental_sx as sx, 7 | ThemeProvider as MuiThemeProvider, 8 | } from '@mui/system'; 9 | import type { PropsWithChildren } from 'react'; 10 | 11 | export const appTheme = responsiveFontSizes( 12 | createTheme({ 13 | components: { 14 | MuiButton: { 15 | variants: [ 16 | { 17 | props: { color: 'primary' }, 18 | style: sx({ 19 | ':hover': { 20 | backgroundColor: 'grey.400', 21 | }, 22 | backgroundColor: 'background.default', 23 | color: 'text.primary', 24 | }), 25 | }, 26 | { 27 | props: { color: 'secondary' }, 28 | style: sx({ 29 | ':hover': { 30 | backgroundColor: 'grey.400', 31 | }, 32 | backgroundColor: 'grey.300', 33 | color: 'text.secondary', 34 | }), 35 | }, 36 | ], 37 | }, 38 | MuiButtonGroup: { 39 | styleOverrides: { 40 | grouped: sx({ 41 | '&:not(:last-child)': { 42 | borderColor: 'grey.100', 43 | }, 44 | }), 45 | }, 46 | }, 47 | }, 48 | palette: { 49 | background: { 50 | paper: '#f5f7fa', 51 | }, 52 | }, 53 | typography: { 54 | htmlFontSize: 10, 55 | }, 56 | }), 57 | ); 58 | 59 | export default function ThemeProvider({ 60 | children, 61 | }: PropsWithChildren) { 62 | return {children}; 63 | } 64 | -------------------------------------------------------------------------------- /systems/infrastructure/src/aws/s3/s3.spec.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from '@pulumi/pulumi'; 2 | import { expect } from 'chai'; 3 | 4 | import { createS3Bucket, uploadTestIndexFile } from './index.js'; 5 | 6 | pulumi.runtime.setMocks({ 7 | call: function (args: pulumi.runtime.MockCallArgs) { 8 | return args.inputs; 9 | }, 10 | newResource: function (args: pulumi.runtime.MockResourceArgs): { 11 | id: string; 12 | state: any; 13 | } { 14 | return { 15 | id: `${args.inputs.name}_id`, 16 | state: args.inputs, 17 | }; 18 | }, 19 | }); 20 | 21 | describe('S3', () => { 22 | describe('createS3Bucket', () => { 23 | it('should create a bucket with cors rule and website served', done => { 24 | const { bucket } = createS3Bucket(); 25 | pulumi 26 | .all([bucket.corsRules, bucket.website]) 27 | .apply(([cors, website]) => { 28 | expect(cors).to.have.length(1); 29 | expect(cors?.[0].allowedMethods).to.deep.equal(['PUT']); 30 | expect(cors?.[0].allowedOrigins).to.deep.equal(['*']); 31 | expect(website?.indexDocument).to.equal('index.html'); 32 | done(); 33 | }); 34 | }); 35 | }); 36 | 37 | describe('uploadTestIndexFile', () => { 38 | it('should upload test index file for prove deployment working', async () => { 39 | const indexFile = await uploadTestIndexFile( 40 | {} as any, 41 | { 42 | apiEndpoint: pulumi.Output.create('https://s3.amazonaws.com'), 43 | } as any, 44 | ); 45 | return new Promise(resolve => 46 | pulumi.all([indexFile.content]).apply(([fileContent]) => { 47 | expect(fileContent).to.be.contain('https://s3.amazonaws.com'); 48 | resolve(); 49 | }), 50 | ); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /systems/backend/src/common/request-id.middleware.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, jest } from '@jest/globals'; 2 | 3 | import { RequestIdMiddleware } from './request-id.middleware'; 4 | 5 | type mockRequestGet = (key: string) => string | undefined; 6 | 7 | describe('Test RequestIdMiddleware', () => { 8 | it("set request id from request header['REQ-ID']", () => { 9 | const request: any = { 10 | get: jest.fn().mockImplementation((key: string) => { 11 | return { 12 | 'REQ-ID': 'foobar', 13 | }[key]; 14 | }), 15 | }; 16 | const response: any = { 17 | locals: {}, 18 | setHeader: jest.fn(), 19 | }; 20 | new RequestIdMiddleware().use(request, response, jest.fn() as any); 21 | expect(response.locals.reqId).toEqual('foobar'); 22 | }); 23 | 24 | it("set request id from request header['X-Amz-Cf-Id']", () => { 25 | const request: any = { 26 | get: jest.fn().mockImplementation(key => { 27 | return { 28 | 'X-Amz-Cf-Id': 'foobar', 29 | }[key]; 30 | }), 31 | }; 32 | const response: any = { 33 | locals: {}, 34 | setHeader: jest.fn(), 35 | }; 36 | new RequestIdMiddleware().use(request, response, jest.fn() as any); 37 | expect(response.locals.reqId).toEqual('foobar'); 38 | }); 39 | 40 | it('generate request id to if requestId not set', () => { 41 | const request: any = { 42 | get: jest.fn().mockImplementation(key => { 43 | return {}[key]; 44 | }), 45 | }; 46 | const response: any = { 47 | locals: {}, 48 | setHeader: jest.fn(), 49 | }; 50 | new RequestIdMiddleware().use(request, response, jest.fn() as any); 51 | expect(response.locals.reqId).toBeDefined(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /systems/infrastructure/src/aws/cloudfront.ts: -------------------------------------------------------------------------------- 1 | import * as aws from '@pulumi/aws'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import kebabcase from 'lodash.kebabcase'; 4 | 5 | export function createCloudFront(bucket: aws.s3.Bucket) { 6 | const namePrefix = kebabcase(pulumi.getStack()); 7 | const originId = pulumi.interpolate`s3-origin-id-${bucket.id}`; 8 | const cloudFrontDistribution = new aws.cloudfront.Distribution( 9 | `${namePrefix}-cloud-front-distribution`, 10 | { 11 | defaultCacheBehavior: { 12 | allowedMethods: [ 13 | 'DELETE', 14 | 'GET', 15 | 'HEAD', 16 | 'OPTIONS', 17 | 'PATCH', 18 | 'POST', 19 | 'PUT', 20 | ], 21 | cachedMethods: ['GET', 'HEAD'], 22 | defaultTtl: 3600, 23 | forwardedValues: { 24 | cookies: { 25 | forward: 'all', 26 | }, 27 | queryString: true, 28 | }, 29 | maxTtl: 86400, 30 | minTtl: 0, 31 | targetOriginId: originId, 32 | viewerProtocolPolicy: 'allow-all', 33 | }, 34 | defaultRootObject: 'index.html', 35 | enabled: true, 36 | isIpv6Enabled: true, 37 | 38 | origins: [ 39 | { 40 | domainName: bucket.bucketRegionalDomainName, 41 | originId: originId, 42 | // s3OriginConfig: { 43 | // originAccessIdentity: pulumi.interpolate`origin-access-identity/cloudfront/${originId}`, 44 | // }, 45 | }, 46 | ], 47 | priceClass: 'PriceClass_100', 48 | restrictions: { 49 | geoRestriction: { 50 | restrictionType: 'none', 51 | }, 52 | }, 53 | viewerCertificate: { 54 | cloudfrontDefaultCertificate: true, 55 | }, 56 | }, 57 | ); 58 | return { cloudFrontDistribution }; 59 | } 60 | -------------------------------------------------------------------------------- /systems/frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /systems/backend/src/test-helpers/jest/e2e-test-environment.js: -------------------------------------------------------------------------------- 1 | const { TestEnvironment } = require('jest-environment-node'); 2 | const path = require('path'); 3 | const typeorm = require('typeorm'); 4 | 5 | const dotenv = require('dotenv'); 6 | 7 | const { createConnection } = typeorm; 8 | 9 | dotenv.config({ path: '.env.test' }); 10 | 11 | function generateTestId(testPath) { 12 | const { dir, name } = path.parse(path.relative(process.cwd(), testPath)); 13 | 14 | const suffix = `e2e-${path 15 | .join(dir, name.replace(/\.(e2e-spec|spec)$/, '')) 16 | .replace(/[/\\. "$]+/g, '-')}`; 17 | 18 | // https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS 19 | const maxLength = 63; 20 | if (suffix.length >= maxLength) { 21 | return `e2e-${suffix 22 | .slice(-maxLength) 23 | .replace(/^./, '-') 24 | .toLowerCase() 25 | .split('-') 26 | .filter(i => i.length > 0) 27 | .join('-')}`; 28 | } 29 | return suffix.toLowerCase(); 30 | } 31 | 32 | async function setupDB(testId) { 33 | const conn = await createConnection({ 34 | type: 'postgres', 35 | url: process.env['DATABASE_CONNECTION_URL'], 36 | }); 37 | await conn.query(`CREATE SCHEMA IF NOT EXISTS "${testId}"`); 38 | await conn.close(); 39 | return testId; 40 | } 41 | 42 | class E2ETestEnvironment extends TestEnvironment { 43 | constructor(config, context) { 44 | super(config, context); 45 | this.testPath = context['testPath']; 46 | } 47 | 48 | async setup() { 49 | await super.setup(); 50 | const testId = generateTestId(this.testPath); 51 | this.global['testConfig'] = { 52 | db: { 53 | schema: await setupDB(testId), 54 | }, 55 | testId, 56 | }; 57 | } 58 | 59 | async teardown() { 60 | await super.teardown(); 61 | } 62 | 63 | getVmContext() { 64 | return super.getVmContext(); 65 | } 66 | } 67 | 68 | module.exports = E2ETestEnvironment; 69 | -------------------------------------------------------------------------------- /systems/backend/src/config/configuration.ts: -------------------------------------------------------------------------------- 1 | import convict from 'convict'; 2 | 3 | import { AppEnvironment, AppMode } from './config.constants'; 4 | 5 | convict.addFormat({ 6 | coerce(val: any): any { 7 | return val.split(','); 8 | }, 9 | name: 'comma-separated-value', 10 | validate(sources) { 11 | return Array.isArray(sources) && sources.length > 0; 12 | }, 13 | }); 14 | 15 | const configSchema = convict({ 16 | database: { 17 | connectionURL: { 18 | default: null, 19 | env: 'DATABASE_CONNECTION_URL', 20 | format: String, 21 | }, 22 | type: { 23 | default: 'postgres', 24 | format: String, 25 | }, 26 | }, 27 | env: { 28 | default: 'development', 29 | env: 'APP_ENV', 30 | format: Object.values(AppEnvironment), 31 | }, 32 | frontend: { 33 | origin: { 34 | default: null, 35 | format: String, 36 | }, 37 | }, 38 | mode: { 39 | default: 'http', 40 | env: 'APP_MODE', 41 | format: Object.values(AppMode), 42 | }, 43 | port: { 44 | default: 5333, 45 | env: 'PORT', 46 | format: 'port', 47 | }, 48 | s3: { 49 | asset: { 50 | bucket: { 51 | default: null, 52 | env: 'S3_ASSET_BUCKET', 53 | format: String, 54 | }, 55 | cloudfront: { 56 | default: null, 57 | env: 'CLOUDFRONT_URL', 58 | format: String, 59 | }, 60 | }, 61 | region: { 62 | default: null, 63 | env: 'S3_REGION', 64 | format: String, 65 | }, 66 | }, 67 | }); 68 | 69 | export function configuration() { 70 | const isPrd = configSchema.get('env') === AppEnvironment.PRD; 71 | configSchema.load({ 72 | frontend: { 73 | origin: isPrd 74 | ? configSchema.get('s3.asset.cloudfront') 75 | : 'http://localhost:3000', 76 | }, 77 | }); 78 | configSchema.validate({ 79 | allowed: 'strict', 80 | }); 81 | 82 | return configSchema.getProperties(); 83 | } 84 | -------------------------------------------------------------------------------- /docs/adr/template.md: -------------------------------------------------------------------------------- 1 | # [short title of solved problem and solution] 2 | 3 | - Status: [proposed | rejected | accepted | deprecated | … | superseded] 4 | - Deciders: [list everyone involved in the decision] 5 | - Date: [YYYY-MM-DD when the decision was last updated] 6 | 7 | Technical Story: [description | ticket/issue URL] 8 | 9 | ## Context and Problem Statement 10 | 11 | ## Decision Drivers 12 | 13 | - [driver 1, e.g., a force, facing concern, …] 14 | - [driver 2, e.g., a force, facing concern, …] 15 | - … 16 | 17 | ## Considered Options 18 | 19 | - [option 1] 20 | - [option 2] 21 | - [option 3] 22 | - … 23 | 24 | ## Decision Outcome 25 | 26 | ### Positive Consequences 27 | 28 | - … 29 | 30 | ### Negative Consequences 31 | 32 | - [e.g., compromising quality attribute, follow-up decisions required, …] 33 | - … 34 | 35 | ## Pros and Cons of the Options 36 | 37 | ### [option 1] 38 | 39 | [example | description | pointer to more information | …] 40 | 41 | - Good, because [argument a] 42 | - Good, because [argument b] 43 | - Bad, because [argument c] 44 | - … 45 | 46 | ### [option 2] 47 | 48 | [example | description | pointer to more information | …] 49 | 50 | - Good, because [argument a] 51 | - Good, because [argument b] 52 | - Bad, because [argument c] 53 | - … 54 | 55 | ### [option 3] 56 | 57 | [example | description | pointer to more information | …] 58 | 59 | - Good, because [argument a] 60 | - Good, because [argument b] 61 | - Bad, because [argument c] 62 | - … 63 | 64 | ## Links 65 | 66 | - [Link type] [Link to ADR] 67 | - … 68 | -------------------------------------------------------------------------------- /systems/backend/src/logging/db-operation-logger.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from '@nestjs/common'; 2 | import type { Logger as TypeORMLogger } from 'typeorm'; 3 | import type { Logform } from 'winston'; 4 | 5 | import { err } from './formats/err'; 6 | import { Logger } from './logger'; 7 | import { Level } from './logging.constants'; 8 | 9 | export class DbOperationLogger implements TypeORMLogger { 10 | constructor(@Inject(Logger) private logger: Logger) {} 11 | 12 | private logToWinston( 13 | message: string, 14 | infoObj: Omit & { 15 | level: Level; 16 | }, 17 | ) { 18 | const { level, ...info } = infoObj; 19 | this.logger.log(level, 'DB Operation', { 20 | ...info, 21 | message, 22 | }); 23 | } 24 | 25 | log(level: 'log' | 'info' | 'warn', message: any) { 26 | this.logToWinston(message, { 27 | level: level === 'log' ? Level.info : Level[level], 28 | }); 29 | } 30 | 31 | logMigration(message: string) { 32 | this.logToWinston(message, { 33 | level: Level.info, 34 | }); 35 | } 36 | 37 | logQuery(query: string, parameters?: any[]) { 38 | this.logToWinston('DB Operation', { 39 | db: { 40 | parameters, 41 | query, 42 | }, 43 | level: Level.info, 44 | }); 45 | } 46 | 47 | logQueryError(error: string | Error, query: string, parameters?: any[]) { 48 | this.logToWinston('DB Operation', { 49 | db: { 50 | err: typeof error === 'string' ? error : err(error), 51 | parameters, 52 | query, 53 | }, 54 | level: Level.error, 55 | }); 56 | } 57 | 58 | logQuerySlow(time: number, query: string, parameters?: any[]) { 59 | this.logToWinston('DB Operation', { 60 | db: { 61 | parameters, 62 | query, 63 | time, 64 | }, 65 | level: Level.warn, 66 | }); 67 | } 68 | 69 | logSchemaBuild(message: string) { 70 | this.logToWinston(message, { 71 | level: Level.info, 72 | }); 73 | } 74 | // implement all methods from logger class 75 | } 76 | -------------------------------------------------------------------------------- /docs/adr/0007-deployment/deploymnet.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | !include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml 3 | !define AWSPuml https://raw.githubusercontent.com/awslabs/aws-icons-for-plantuml/v11.1/dist 4 | !define ICONURL https://raw.githubusercontent.com/tupadr3/plantuml-icon-font-sprites/v2.4.0 5 | 6 | !includeurl AWSPuml/AWSCommon.puml 7 | !includeurl AWSPuml/Storage/SimpleStorageService.puml 8 | !includeurl AWSPuml/Compute/Lambda.puml 9 | !includeurl AWSPuml/ApplicationIntegration/APIGateway.puml 10 | !includeurl AWSPuml/NetworkingContentDelivery/CloudFront.puml 11 | !includeurl AWSPuml/Database/AuroraPostgreSQLInstance.puml 12 | !includeurl AWSPuml/Containers/ElasticContainerRegistry.puml 13 | !includeurl AWSPuml/Containers/ElasticContainerRegistryImage.puml 14 | 15 | !includeurl ICONURL/devicons/react.puml 16 | !includeurl ICONURL/devicons2/nestjs.puml 17 | !includeurl ICONURL/font-awesome-5/user.puml 18 | !includeurl ICONURL/font-awesome-5/aws.puml 19 | 20 | LAYOUT_WITH_LEGEND() 21 | 22 | Person(user, "User", "People that need products", $sprite="user") 23 | 24 | System_Boundary(aws, "AWS Cloud"){ 25 | System(cloudFront,"CloudFront","CDN",$sprite="CloudFront") 26 | System(s3, "S3", $sprite="SimpleStorageService") { 27 | Container(frontend, "Web SPA","react", $sprite="react") 28 | } 29 | System(apigw,"API Gateway",$sprite="APIGateway") 30 | System(lambda,"Lambda",$sprite="Lambda") 31 | System(db,"RDS Aurora",$sprite="AuroraPostgreSQLInstance") 32 | System(ecr,"ECR",$sprite="ElasticContainerRegistry") { 33 | Container(backend_image, "Docker Image", $sprite="ElasticContainerRegistryImage") { 34 | Container(backend, "Backend","nest-js", $sprite="nestjs") 35 | } 36 | } 37 | } 38 | 39 | Rel(user, cloudFront, "Uses", "HTTPS") 40 | Rel(cloudFront, s3, "Get document") 41 | Rel(cloudFront, user, "cached document") 42 | Rel(user, apigw, "AJAX") 43 | Rel(apigw, lambda, "Invoke") 44 | Rel(apigw, user, "Backend Response", "JSON") 45 | 46 | Rel(lambda, backend_image, "download and serve") 47 | Rel(backend, db, "Persist / Query data") 48 | 49 | 50 | 51 | @enduml -------------------------------------------------------------------------------- /systems/backend/src/migrations/1647947240838-create-game-table.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, Table } from 'typeorm'; 2 | 3 | export class createGameTable1647947240838 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.createTable( 6 | new Table({ 7 | columns: [ 8 | { 9 | default: 'uuid_generate_v4()', 10 | isPrimary: true, 11 | name: 'id', 12 | type: 'uuid', 13 | }, 14 | { 15 | isNullable: false, 16 | name: 'platform', 17 | type: 'CHAR(32)', 18 | }, 19 | { 20 | isNullable: false, 21 | name: 'publisher', 22 | type: 'CHAR(128)', 23 | }, 24 | { 25 | isNullable: false, 26 | name: 'number_of_players', 27 | type: 'smallint', 28 | }, 29 | { 30 | isNullable: false, 31 | name: 'user_id', 32 | type: 'uuid', 33 | }, 34 | { 35 | isNullable: false, 36 | name: 'name', 37 | type: 'VARCHAR', 38 | }, 39 | { 40 | isNullable: false, 41 | name: 'genre', 42 | type: 'CHAR(128)', 43 | }, 44 | { 45 | isNullable: false, 46 | name: 'release_date', 47 | type: 'TIMESTAMP WITHOUT TIME ZONE', 48 | }, 49 | { 50 | isNullable: true, 51 | name: 'box_art_image_url', 52 | type: 'TEXT', 53 | }, 54 | { 55 | default: 'NOW()', 56 | isNullable: false, 57 | name: 'updated_at', 58 | type: 'TIMESTAMP', 59 | }, 60 | { 61 | default: 'NOW()', 62 | isNullable: false, 63 | name: 'created_at', 64 | type: 'TIMESTAMP', 65 | }, 66 | ], 67 | name: 'game', 68 | }), 69 | ); 70 | } 71 | 72 | public async down(queryRunner: QueryRunner): Promise { 73 | await queryRunner.dropTable(new Table({ name: 'game' })); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /systems/frontend/src/Layout/sony_logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /systems/infrastructure/src/aws/s3/index.ts: -------------------------------------------------------------------------------- 1 | import * as aws from '@pulumi/aws'; 2 | import * as pulumi from '@pulumi/pulumi'; 3 | import { promises as fs } from 'fs'; 4 | import Handlebars from 'handlebars'; 5 | import kebabcase from 'lodash.kebabcase'; 6 | import path from 'path'; 7 | 8 | const currentDir = path.parse(new URL(import.meta.url).pathname).dir; 9 | 10 | function publicReadPolicyForBucket(bucketName: string) { 11 | return JSON.stringify({ 12 | Statement: [ 13 | { 14 | Action: ['s3:GetObject'], 15 | Effect: 'Allow', 16 | Principal: '*', 17 | Resource: [ 18 | `arn:aws:s3:::${bucketName}/*`, // policy refers to bucket name explicitly 19 | ], 20 | }, 21 | ], 22 | Version: '2012-10-17', 23 | }); 24 | } 25 | 26 | export function createS3Bucket() { 27 | const namePrefix = kebabcase(pulumi.getStack()); 28 | // Create an AWS resource (S3 Bucket) 29 | const bucket = new aws.s3.Bucket(`${namePrefix}-bucket`, { 30 | corsRules: [ 31 | { 32 | allowedHeaders: ['*'], 33 | allowedMethods: ['PUT'], 34 | allowedOrigins: ['*'], 35 | exposeHeaders: ['ETag'], 36 | maxAgeSeconds: 3000, 37 | }, 38 | ], 39 | website: { 40 | indexDocument: 'index.html', 41 | }, 42 | }); 43 | 44 | new aws.s3.BucketPolicy('bucketPolicy', { 45 | bucket: bucket.bucket, 46 | policy: bucket.bucket.apply(publicReadPolicyForBucket), 47 | }); 48 | 49 | new aws.s3.BucketObject('demo-upload-image', { 50 | bucket: bucket, 51 | contentType: 'image/gif', 52 | key: 'upload/demo.gif', 53 | source: new pulumi.asset.FileAsset(path.join(currentDir, 'demo.gif')), 54 | }); 55 | return { 56 | bucket, 57 | }; 58 | } 59 | 60 | export async function uploadTestIndexFile( 61 | bucket: aws.s3.Bucket, 62 | api: aws.apigatewayv2.Api, 63 | ) { 64 | const template = Handlebars.compile( 65 | await fs.readFile(path.join(currentDir, 'index.hbs'), 'utf-8'), 66 | ); 67 | 68 | return new aws.s3.BucketObject('demo-index-html', { 69 | bucket: bucket, 70 | content: api.apiEndpoint.apply(apiEndpoint => 71 | template({ endpoint: apiEndpoint }), 72 | ), 73 | contentType: 'text/html', 74 | key: 'index.html', 75 | }); 76 | } 77 | -------------------------------------------------------------------------------- /systems/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "browserslist": { 3 | "development": [ 4 | "last 1 chrome version", 5 | "last 1 firefox version", 6 | "last 1 safari version" 7 | ], 8 | "production": [ 9 | ">0.2%", 10 | "not dead", 11 | "not op_mini all" 12 | ] 13 | }, 14 | "dependencies": { 15 | "@apollo/client": "3.6.5", 16 | "@emotion/react": "11.9.0", 17 | "@emotion/styled": "11.8.1", 18 | "@mui/lab": "5.0.0-alpha.83", 19 | "@mui/material": "5.8.1", 20 | "@mui/system": "5.8.1", 21 | "apollo-upload-client": "17.0.0", 22 | "graphql": "16.5.0", 23 | "luxon": "2.4.0", 24 | "react": "18.1.0", 25 | "react-dom": "18.1.0", 26 | "react-hook-form": "7.31.2", 27 | "react-router-dom": "6.3.0", 28 | "web-vitals": "2.1.4" 29 | }, 30 | "devDependencies": { 31 | "@busybox/eslint-config": "5.10.0", 32 | "@busybox/tsconfig": "1.7.1", 33 | "@cypress/react": "5.12.5", 34 | "@cypress/webpack-dev-server": "1.8.4", 35 | "@hookform/devtools": "4.4.0", 36 | "@stylelint/postcss-css-in-js": "0.38.0", 37 | "@types/apollo-upload-client": "17.0.0", 38 | "@types/react": "18.0.9", 39 | "@types/react-dom": "18.0.2", 40 | "cypress": "9.7.0", 41 | "eslint": "8.16.0", 42 | "eslint-plugin-cypress": "2.12.1", 43 | "postcss-syntax": "0.36.2", 44 | "prettier": "2.6.2", 45 | "react-scripts": "5.0.1", 46 | "start-server-and-test": "1.14.0", 47 | "stylelint": "14.8.5", 48 | "stylelint-config-styled-components": "0.1.1", 49 | "stylelint-order": "5.0.0", 50 | "typescript": "4.7.2" 51 | }, 52 | "engines": { 53 | "node": ">=14", 54 | "yarn": "Use npm" 55 | }, 56 | "license": "MIT", 57 | "name": "frontend", 58 | "private": true, 59 | "scripts": { 60 | "build": "DISABLE_ESLINT_PLUGIN=true react-scripts build", 61 | "eject": "DISABLE_ESLINT_PLUGIN=true react-scripts eject", 62 | "lint:css": "npx stylelint './src/**/*.{tsx,css}'", 63 | "lint:js": "npx eslint --ext=json,ts,tsx,yml --fix .", 64 | "lint:js:ci": "npx eslint --ext=json,tsx,ts,yml .", 65 | "start": "DISABLE_ESLINT_PLUGIN=true react-scripts start", 66 | "test": "DISABLE_ESLINT_PLUGIN=true npx cypress open-ct", 67 | "test:ci": "DISABLE_ESLINT_PLUGIN=true npx cypress run-ct" 68 | }, 69 | "version": "0.1.0" 70 | } 71 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | # [1.3.0](https://github.com/davidNHK/project-bootstrap/compare/v1.1.0...v1.3.0) (2022-04-02) 7 | 8 | ### Features 9 | 10 | - backend api always return http status 200 on graphql endpoint ([612c553](https://github.com/davidNHK/project-bootstrap/commit/612c5534a195a0e1995d9d2ac5ff80bdd81e0796)) 11 | - enable auto update github action workflow file ([c38fd3e](https://github.com/davidNHK/project-bootstrap/commit/c38fd3e2f2447eabdc025b1511dcc139ee4d3e61)) 12 | - enable lambda provisioned concurrency ([ec3a132](https://github.com/davidNHK/project-bootstrap/commit/ec3a13276930dfd95f4ab27b60c874b17806d700)) 13 | - mobile responsive ([0c426b7](https://github.com/davidNHK/project-bootstrap/commit/0c426b7af62d4ce25669028a38bc8e7672c68742)) 14 | - redeploy whole aws stack ([9fec672](https://github.com/davidNHK/project-bootstrap/commit/9fec67295018d2c52854c2e7adc0cc579dbf5dec)) 15 | - use webpack on lambda function ([fd51681](https://github.com/davidNHK/project-bootstrap/commit/fd5168191513d44cc2544564c154869336ebe013)) 16 | 17 | # [1.2.0](https://github.com/davidNHK/project-bootstrap/compare/v1.1.0...v1.2.0) (2022-03-28) 18 | 19 | ### Features 20 | 21 | - enable lambda provisioned concurrency ([ec3a132](https://github.com/davidNHK/project-bootstrap/commit/ec3a13276930dfd95f4ab27b60c874b17806d700)) 22 | - redeploy whole aws stack ([9fec672](https://github.com/davidNHK/project-bootstrap/commit/9fec67295018d2c52854c2e7adc0cc579dbf5dec)) 23 | - set rem base to 10 and strict follow 8pt grid ([7bc22c9](https://github.com/davidNHK/project-bootstrap/commit/7bc22c99ac8333a33bf66d38425fcb1f763c8c18)) 24 | 25 | # [1.1.0](https://github.com/davidNHK/project-bootstrap/compare/v1.0.4...v1.1.0) (2022-03-27) 26 | 27 | ### Features 28 | 29 | - enable lambda provisioned concurrency ([7340014](https://github.com/davidNHK/project-bootstrap/commit/73400149f3cc29080c79a970c34cc5e86855c770)) 30 | - redeploy whole aws stack ([00060f2](https://github.com/davidNHK/project-bootstrap/commit/00060f2d29d6211ae2d20264cfec0e544c9c26b4)) 31 | 32 | ## [1.0.4](https://github.com/davidNHK/project-bootstrap/compare/v1.0.2...v1.0.4) (2022-03-26) 33 | 34 | **Note:** Version bump only for package app 35 | 36 | ## [1.0.3](https://github.com/davidNHK/project-bootstrap/compare/v1.0.2...v1.0.3) (2022-03-26) 37 | 38 | **Note:** Version bump only for package app 39 | -------------------------------------------------------------------------------- /systems/backend/src/logging/general-logging.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | Injectable, 5 | Logger, 6 | NestInterceptor, 7 | } from '@nestjs/common'; 8 | import { GqlExecutionContext } from '@nestjs/graphql'; 9 | import type { Request, Response } from 'express'; 10 | import type { Observable } from 'rxjs'; 11 | import { tap } from 'rxjs/operators'; 12 | 13 | import { graphql } from './formats/graphql'; 14 | import { http } from './formats/http'; 15 | 16 | @Injectable() 17 | export class GeneralLoggingInterceptor implements NestInterceptor { 18 | private logger = new Logger(GeneralLoggingInterceptor.name); 19 | 20 | intercept(context: ExecutionContext, next: CallHandler): Observable { 21 | const isGraphql = context.getType().toString() === 'graphql'; 22 | 23 | if (!isGraphql) return this.interceptHttpRequest(context, next); 24 | return this.interceptGraphqlRequest(context, next); 25 | } 26 | 27 | interceptHttpRequest( 28 | context: ExecutionContext, 29 | next: CallHandler, 30 | ): Observable { 31 | const request = context.switchToHttp().getRequest(); 32 | const response = context.switchToHttp().getResponse(); 33 | return next.handle().pipe( 34 | tap(data => { 35 | const { startAt } = response.locals; 36 | const end = new Date().getTime(); 37 | this.logger.log( 38 | { 39 | duration: end - startAt, 40 | http: http( 41 | request, 42 | Object.assign(response, { 43 | body: data, 44 | }), 45 | ), 46 | message: 'Access Log', 47 | }, 48 | GeneralLoggingInterceptor.name, 49 | ); 50 | }), 51 | ); 52 | } 53 | 54 | interceptGraphqlRequest( 55 | context: ExecutionContext, 56 | next: CallHandler, 57 | ): Observable { 58 | const ctx = GqlExecutionContext.create(context); 59 | const { req, res } = ctx.getContext<{ req: Request; res: Response }>(); 60 | const end = new Date().getTime(); 61 | const { startAt = end } = res?.locals ?? {}; 62 | return next.handle().pipe( 63 | tap(data => { 64 | this.logger.log( 65 | { 66 | duration: end - startAt, 67 | graphql: graphql(ctx), 68 | http: http(req, Object.assign(res || {}, { body: data })), 69 | message: 'Access Log', 70 | }, 71 | GeneralLoggingInterceptor.name, 72 | ); 73 | }), 74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /systems/backend/src/game-gallery/game.service.ts: -------------------------------------------------------------------------------- 1 | import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; 2 | import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; 3 | import { Injectable } from '@nestjs/common'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { InjectRepository } from '@nestjs/typeorm'; 6 | import { randomUUID } from 'crypto'; 7 | import path from 'path'; 8 | import { ILike, Repository } from 'typeorm'; 9 | 10 | import { AppEnvironment } from '../config/config.constants'; 11 | import type { AddGameToLibraryArgs } from './dto/add-game-to-library.args'; 12 | import type { GetGameListArgs } from './dto/get-game-list.args'; 13 | import { GameEntity } from './models/game.entity'; 14 | import type { GameList } from './models/game.model'; 15 | 16 | @Injectable() 17 | export class GameService { 18 | constructor( 19 | @InjectRepository(GameEntity) 20 | private gameRepository: Repository, 21 | private config: ConfigService, 22 | ) {} 23 | 24 | async preSignUploadBoxArtUrl({ fileName }: { fileName: string }) { 25 | const env = this.config.get('env'); 26 | const bucket = this.config.get('s3.asset.bucket'); 27 | const cloudFrontUrl = this.config.get('s3.asset.cloudfront'); 28 | const isPrd = ![AppEnvironment.TEST, AppEnvironment.DEV].includes(env); 29 | const id = randomUUID(); 30 | const s3 = new S3Client({ 31 | region: this.config.get('s3.region'), 32 | ...(!isPrd 33 | ? { 34 | endpoint: 'http://localhost:4566', 35 | forcePathStyle: true, 36 | } 37 | : {}), 38 | }); 39 | const key = path.join('upload', 'box-art', `${id}-${fileName}`); 40 | 41 | const preSignedUrl = await getSignedUrl( 42 | s3, 43 | new PutObjectCommand({ 44 | ACL: 'public-read', 45 | Bucket: bucket, 46 | Key: key, 47 | }), 48 | { 49 | expiresIn: 30, 50 | }, 51 | ); 52 | 53 | const resultUrl = `${cloudFrontUrl}/${path.join( 54 | ...(isPrd ? [key] : [bucket, key]), 55 | )}`; 56 | return { id, resultPublicUrl: resultUrl, uploadUrl: preSignedUrl }; 57 | } 58 | 59 | async fineGamesList(args: GetGameListArgs): Promise { 60 | const { name, platform, userId } = args; 61 | const where = []; 62 | if (platform) where.push(['platform', platform]); 63 | if (name) where.push(['name', ILike(`%${name}%`)]); 64 | if (userId) where.push(['userId', userId]); 65 | const [records, totalCount] = await this.gameRepository.findAndCount({ 66 | order: { updatedAt: 'DESC' }, 67 | skip: args.offset, 68 | take: args.limit, 69 | where: Object.fromEntries(where), 70 | }); 71 | const recordLength = records.length; 72 | return { 73 | edges: records.map(record => ({ 74 | node: record, 75 | })), 76 | pageInfo: { 77 | hasNextPage: args.offset + recordLength < totalCount, 78 | }, 79 | totalCount, 80 | }; 81 | } 82 | 83 | fineGame(id: string) { 84 | return this.gameRepository.findOneOrFail({ id }); 85 | } 86 | 87 | async createGame(args: AddGameToLibraryArgs) { 88 | const [createdGame] = await this.gameRepository.save([args]); 89 | return createdGame; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /systems/infrastructure/src/aws/lambda.ts: -------------------------------------------------------------------------------- 1 | import * as aws from '@pulumi/aws'; 2 | import type * as awsx from '@pulumi/awsx'; 3 | import * as pulumi from '@pulumi/pulumi'; 4 | import kebabcase from 'lodash.kebabcase'; 5 | 6 | export async function createLambda( 7 | image: awsx.ecr.RepositoryImage, 8 | { 9 | cloudFrontDistribution, 10 | rds, 11 | s3Bucket, 12 | vpc, 13 | }: { 14 | cloudFrontDistribution: aws.cloudfront.Distribution; 15 | rds: aws.rds.Cluster; 16 | s3Bucket: aws.s3.Bucket; 17 | vpc: awsx.ec2.Vpc; 18 | }, 19 | ) { 20 | const namePrefix = kebabcase(pulumi.getStack()); 21 | 22 | const role = new aws.iam.Role(`${namePrefix}-lambda-vpc-role`, { 23 | assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({ 24 | Service: 'lambda.amazonaws.com', 25 | }), 26 | }); 27 | new aws.iam.RolePolicyAttachment( 28 | `${namePrefix}-lambda-vpc-role-policy-lambda-vpc-access`, 29 | { 30 | policyArn: aws.iam.ManagedPolicy.AWSLambdaVPCAccessExecutionRole, 31 | role: role, 32 | }, 33 | ); 34 | new aws.iam.RolePolicyAttachment( 35 | `${namePrefix}-lambda-vpc-role-policy-lambda-full-access`, 36 | { 37 | policyArn: aws.iam.ManagedPolicy.LambdaFullAccess, 38 | role: role, 39 | }, 40 | ); 41 | new aws.iam.RolePolicyAttachment( 42 | `${namePrefix}-lambda-vpc-role-policy-lambda-rds-full-access`, 43 | { 44 | policyArn: aws.iam.ManagedPolicy.AmazonRDSFullAccess, 45 | role: role, 46 | }, 47 | ); 48 | new aws.iam.RolePolicyAttachment( 49 | `${namePrefix}-lambda-vpc-role-policy-lambda-s3-full-access`, 50 | { 51 | policyArn: aws.iam.ManagedPolicy.AmazonS3FullAccess, 52 | role: role, 53 | }, 54 | ); 55 | 56 | const lambdaFunction = new aws.lambda.Function(`${namePrefix}-lambda`, { 57 | environment: { 58 | variables: { 59 | APP_ENV: 'production', 60 | APP_MODE: 'lambda', 61 | CLOUDFRONT_URL: pulumi.interpolate`https://${cloudFrontDistribution.domainName}`, 62 | // Insecurity, but this is a demo. 63 | DATABASE_CONNECTION_URL: pulumi.interpolate`postgres://${ 64 | rds.masterUsername 65 | }:${rds.masterPassword.apply(pw => encodeURIComponent(pw!))}@${ 66 | rds.endpoint 67 | }:${rds.port}/${rds.databaseName}`, 68 | NODE_ENV: 'production', 69 | S3_ASSET_BUCKET: s3Bucket.bucket, 70 | S3_REGION: 'eu-west-2', 71 | }, 72 | }, 73 | imageConfig: { 74 | commands: ['main-lambda.handler'], 75 | }, 76 | imageUri: image.imageValue, 77 | packageType: 'Image', 78 | publish: true, 79 | role: role.arn, 80 | timeout: 60, 81 | vpcConfig: { 82 | securityGroupIds: rds.vpcSecurityGroupIds, 83 | subnetIds: vpc.privateSubnetIds, 84 | }, 85 | }); 86 | const lambdaLatestVersionAlias = new aws.lambda.Alias( 87 | `${namePrefix}-latest-alias`, 88 | { 89 | functionName: lambdaFunction.arn, 90 | functionVersion: '1', 91 | }, 92 | ); 93 | new aws.lambda.ProvisionedConcurrencyConfig( 94 | `${namePrefix}-lambda-provision-config`, 95 | { 96 | functionName: lambdaLatestVersionAlias.functionName, 97 | provisionedConcurrentExecutions: 1, 98 | qualifier: lambdaLatestVersionAlias.name, 99 | }, 100 | ); 101 | return { lambdaFunction, lambdaLatestVersionAlias }; 102 | } 103 | -------------------------------------------------------------------------------- /systems/frontend/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in your browser. 13 | 14 | The page will reload when you make changes.\ 15 | You may also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can't go back!** 35 | 36 | If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own. 39 | 40 | You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | 48 | ### Code Splitting 49 | 50 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) 51 | 52 | ### Analyzing the Bundle Size 53 | 54 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) 55 | 56 | ### Making a Progressive Web App 57 | 58 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) 59 | 60 | ### Advanced Configuration 61 | 62 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) 63 | 64 | ### Deployment 65 | 66 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) 67 | 68 | ### `npm run build` fails to minify 69 | 70 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) 71 | -------------------------------------------------------------------------------- /systems/backend/src/test-helpers/nest-app-context.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, afterEach, beforeAll, beforeEach } from '@jest/globals'; 2 | import type { INestApplication } from '@nestjs/common'; 3 | import type { ModuleMetadata } from '@nestjs/common/interfaces/modules/module-metadata.interface'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import type { NestExpressApplication } from '@nestjs/platform-express'; 6 | import { Test, TestingModule, TestingModuleBuilder } from '@nestjs/testing'; 7 | 8 | import { AppModule } from '../app.module'; 9 | import { setupApp } from '../bootstrap'; 10 | import { NestLogger } from '../logging/nest-logger'; 11 | import { JestModule } from './jest/jest.module'; 12 | 13 | interface NestServerContext { 14 | app: INestApplication; 15 | } 16 | 17 | interface NestModuleBuilderContext { 18 | module: TestingModule; 19 | moduleBuilder: TestingModuleBuilder; 20 | } 21 | 22 | function createTestingModuleBuilder( 23 | moduleMetadata: ModuleMetadata, 24 | ): TestingModuleBuilder { 25 | return Test.createTestingModule({ 26 | controllers: moduleMetadata.controllers ?? [], 27 | imports: [ 28 | JestModule.forRoot(), 29 | AppModule, 30 | ...(moduleMetadata.imports ?? []), 31 | ], 32 | providers: moduleMetadata.providers ?? [], 33 | }); 34 | } 35 | 36 | async function createTestingApp( 37 | module: TestingModule, 38 | ): Promise { 39 | const app = module.createNestApplication(); 40 | 41 | return setupApp(app); 42 | } 43 | 44 | export async function startTestingServer(app: INestApplication) { 45 | const config = app.get(ConfigService); 46 | const port = 47 | config.get('port') + parseInt(process.env['JEST_WORKER_ID']!, 10); 48 | // https://jestjs.io/docs/en/environment-variables 49 | await app.listen(port); 50 | return app; 51 | } 52 | 53 | async function createTestingServer( 54 | moduleMetadata: ModuleMetadata, 55 | ): Promise { 56 | const moduleBuilder = createTestingModuleBuilder(moduleMetadata); 57 | const module = await moduleBuilder.compile(); 58 | const app = await createTestingApp(module); 59 | await startTestingServer(app); 60 | return { 61 | app, 62 | }; 63 | } 64 | 65 | export function withNestServerContext( 66 | moduleMetadata: ModuleMetadata, 67 | ): NestServerContext { 68 | // @ts-expect-error context need assign on beforeAll hooks and must available 69 | const context: AppContext = {}; 70 | beforeAll(async () => { 71 | const { app } = await createTestingServer(moduleMetadata); 72 | Object.assign(context, { app }); 73 | }); 74 | afterAll(async () => { 75 | await context?.app?.close(); 76 | }); 77 | return context; 78 | } 79 | 80 | export function withNestModuleBuilderContext( 81 | moduleMetadata: ModuleMetadata, 82 | ): NestModuleBuilderContext { 83 | // @ts-expect-error context need assign on beforeAll hooks and must available 84 | const context: NestModuleBuilderContext = {}; 85 | beforeEach(async () => { 86 | const moduleBuilder = await createTestingModuleBuilder(moduleMetadata); 87 | const orgCompile = moduleBuilder.compile.bind(moduleBuilder); 88 | moduleBuilder.compile = async () => { 89 | const module = await orgCompile(); 90 | const logger = module.get(NestLogger); 91 | module.useLogger(logger); 92 | await module.init(); 93 | await module.enableShutdownHooks(); 94 | context.module = module; 95 | return module; 96 | }; 97 | Object.assign(context, { moduleBuilder }); 98 | }); 99 | afterEach(async () => { 100 | await context?.module?.close(); 101 | }); 102 | return context; 103 | } 104 | -------------------------------------------------------------------------------- /systems/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "", 3 | "dependencies": { 4 | "@aws-sdk/client-s3": "3.953.0", 5 | "@aws-sdk/s3-request-presigner": "3.953.0", 6 | "@nestjs/apollo": "13.2.3", 7 | "@nestjs/common": "11.1.9", 8 | "@nestjs/config": "4.0.2", 9 | "@nestjs/core": "11.1.9", 10 | "@nestjs/graphql": "13.2.3", 11 | "@nestjs/platform-express": "11.1.9", 12 | "@nestjs/swagger": "11.2.3", 13 | "@nestjs/terminus": "11.0.0", 14 | "@nestjs/typeorm": "11.0.0", 15 | "@vendia/serverless-express": "4.12.6", 16 | "apollo-server-errors": "3.3.1", 17 | "apollo-server-express": "3.13.0", 18 | "aws-lambda": "1.0.7", 19 | "class-transformer": "0.5.1", 20 | "class-validator": "0.14.3", 21 | "convict": "6.2.4", 22 | "dotenv": "17.2.3", 23 | "graphql": "16.5.0", 24 | "helmet": "8.1.0", 25 | "luxon": "2.4.0", 26 | "pg": "8.16.3", 27 | "pg-pool": "3.10.1", 28 | "ramda": "0.32.0", 29 | "reflect-metadata": "0.2.2", 30 | "rimraf": "6.1.2", 31 | "rxjs": "7.8.2", 32 | "serialize-error": "8.1.0", 33 | "swagger-ui-express": "5.0.1", 34 | "typeorm": "0.3.28", 35 | "typeorm-naming-strategies": "4.1.0", 36 | "uuid": "13.0.0", 37 | "winston": "3.19.0" 38 | }, 39 | "description": "", 40 | "devDependencies": { 41 | "@busybox/eslint-config": "5.10.0", 42 | "@busybox/tsconfig": "1.7.1", 43 | "@jest/globals": "30.2.0", 44 | "@nestjs/cli": "11.0.14", 45 | "@nestjs/schematics": "11.0.9", 46 | "@nestjs/testing": "11.1.9", 47 | "@types/aws-lambda": "8.10.159", 48 | "@types/convict": "6.1.6", 49 | "@types/express": "5.0.6", 50 | "@types/luxon": "3.7.1", 51 | "@types/node": "25.0.3", 52 | "@types/passport-jwt": "4.0.1", 53 | "@types/ramda": "0.31.1", 54 | "@types/supertest": "6.0.3", 55 | "@types/uuid": "11.0.0", 56 | "eslint": "8.16.0", 57 | "graphql-tag": "2.12.6", 58 | "jest": "28.1.0", 59 | "jest-environment-node": "30.2.0", 60 | "jest-mock": "28.1.0", 61 | "supertest": "7.1.4", 62 | "ts-jest": "28.0.3", 63 | "ts-loader": "9.5.4", 64 | "ts-node": "10.9.2", 65 | "tsconfig-paths": "4.2.0", 66 | "typescript": "4.7.2" 67 | }, 68 | "engines": { 69 | "node": ">=14", 70 | "yarn": "Use npm" 71 | }, 72 | "jest": { 73 | "collectCoverageFrom": [ 74 | "/**/*.ts", 75 | "!/**/*.spec.ts", 76 | "!/migrations/*.ts" 77 | ], 78 | "coverageDirectory": "/../coverage", 79 | "globalSetup": "/test-helpers/jest/e2e-global-setup.js", 80 | "injectGlobals": false, 81 | "moduleFileExtensions": [ 82 | "js", 83 | "json", 84 | "ts" 85 | ], 86 | "rootDir": "src", 87 | "testEnvironment": "/test-helpers/jest/e2e-test-environment.js", 88 | "testRegex": ".*\\.spec\\.ts$", 89 | "transform": { 90 | "^.+\\.ts$": "ts-jest" 91 | } 92 | }, 93 | "license": "UNLICENSED", 94 | "name": "backend", 95 | "private": true, 96 | "scripts": { 97 | "build": "nest build", 98 | "build:lambda": "nest build --webpack -c nest-cli.lambda.json", 99 | "eslint": "eslint --ext=json,ts,yml", 100 | "lint": "npx eslint --ext=json,ts,yml --fix .", 101 | "lint:ci": "npx eslint --ext=json,ts,yml .", 102 | "migration:create": "npx typeorm migration:create --config ./ormconfig.cli.js", 103 | "prebuild": "rimraf dist", 104 | "start": "nest start", 105 | "start:dev": "nest start --watch", 106 | "start:prod": "node dist/main.http.js", 107 | "test": "jest --maxWorkers=50%", 108 | "test:ci": "APP_ENV=test NODE_ENV=test Ci=true jest --coverage --ci --runInBand --bail" 109 | }, 110 | "version": "1.3.0" 111 | } 112 | -------------------------------------------------------------------------------- /systems/backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; 2 | import { 3 | HttpStatus, 4 | MiddlewareConsumer, 5 | Module, 6 | ValidationPipe, 7 | } from '@nestjs/common'; 8 | import { ConfigModule, ConfigService } from '@nestjs/config'; 9 | import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; 10 | import { GraphQLModule } from '@nestjs/graphql'; 11 | import { TerminusModule } from '@nestjs/terminus'; 12 | 13 | import { CommonModule } from './common/common.module'; 14 | import { RequestIdMiddleware } from './common/request-id.middleware'; 15 | import { RequestStartTimeMiddleware } from './common/request-start-time.middleware'; 16 | import { AppEnvironment } from './config/config.constants'; 17 | import { configuration } from './config/configuration'; 18 | import { getEnvFilePath } from './config/getEnvFilePath'; 19 | import { DatabaseModule } from './database/database.module'; 20 | import { BadRequestException } from './error-hanlding/bad-request.exception'; 21 | import { ErrorCode } from './error-hanlding/error-code.constant'; 22 | import { GeneralExceptionFilter } from './error-hanlding/general-exception.filter'; 23 | import { GameGalleryModule } from './game-gallery/game-gallery.module'; 24 | import { HealthModule } from './health-check/health.module'; 25 | import { GeneralLoggingInterceptor } from './logging/general-logging.interceptor'; 26 | import { LoggingModule } from './logging/logging.module'; 27 | import { SeederModule } from './test-helpers/seeder/seeder.module'; 28 | 29 | @Module({ 30 | controllers: [], 31 | imports: [ 32 | ConfigModule.forRoot({ 33 | envFilePath: getEnvFilePath(), 34 | load: [ 35 | async () => { 36 | const value = await configuration(); 37 | return value; 38 | }, 39 | ], 40 | }), 41 | LoggingModule, 42 | DatabaseModule.forRoot(), 43 | CommonModule, 44 | GraphQLModule.forRootAsync({ 45 | driver: ApolloDriver, 46 | imports: [ConfigModule], 47 | inject: [ConfigService], 48 | useFactory: async (configService: ConfigService) => { 49 | const shouldGenerateSchemaFile = [AppEnvironment.DEV].includes( 50 | configService.get('env')!, 51 | ); 52 | return { 53 | autoSchemaFile: !shouldGenerateSchemaFile ? true : 'schema.graphql', 54 | autoTransformHttpErrors: true, 55 | context: ({ req, res }) => ({ 56 | req, 57 | res, 58 | }), 59 | formatResponse(response, context) { 60 | return { 61 | ...response, 62 | http: { 63 | ...context.response?.http, 64 | status: HttpStatus.OK, 65 | } as any, 66 | }; 67 | }, 68 | sortSchema: true, 69 | }; 70 | }, 71 | }), 72 | GameGalleryModule, 73 | TerminusModule, 74 | HealthModule, 75 | SeederModule, 76 | ], 77 | providers: [ 78 | { 79 | provide: APP_FILTER, 80 | useClass: GeneralExceptionFilter, 81 | }, 82 | { 83 | provide: APP_INTERCEPTOR, 84 | useClass: GeneralLoggingInterceptor, 85 | }, 86 | { 87 | provide: APP_PIPE, 88 | useValue: new ValidationPipe({ 89 | exceptionFactory(errors) { 90 | throw new BadRequestException({ 91 | code: ErrorCode.ValidationError, 92 | errors: errors.map(error => ({ 93 | detail: error.toString(), 94 | title: 'Validation Error', 95 | })), 96 | meta: { errors }, 97 | }); 98 | }, 99 | transform: true, 100 | whitelist: false, 101 | }), 102 | }, 103 | ], 104 | }) 105 | export class AppModule { 106 | configure(consumer: MiddlewareConsumer) { 107 | consumer 108 | .apply(RequestStartTimeMiddleware, RequestIdMiddleware) 109 | .forRoutes('*'); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /systems/backend/src/logging/general-logging.interceptor.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | afterEach, 3 | beforeEach, 4 | describe, 5 | expect, 6 | it, 7 | jest, 8 | } from '@jest/globals'; 9 | import { getApolloServer } from '@nestjs/apollo'; 10 | import { Controller, Get, LoggerService } from '@nestjs/common'; 11 | import { Field, ID, ObjectType, Query, Resolver } from '@nestjs/graphql'; 12 | import type { NestExpressApplication } from '@nestjs/platform-express'; 13 | import gql from 'graphql-tag'; 14 | import type { Mock } from 'jest-mock'; 15 | 16 | import { expectResponseCode } from '../test-helpers/expect-response-code'; 17 | import { getRequestAgent } from '../test-helpers/get-request-agent'; 18 | import { 19 | startTestingServer, 20 | withNestModuleBuilderContext, 21 | } from '../test-helpers/nest-app-context'; 22 | 23 | @ObjectType() 24 | class TestModel { 25 | @Field(() => ID) 26 | id!: string; 27 | } 28 | 29 | @Resolver(() => TestModel) 30 | class TestResolver { 31 | @Query(() => TestModel) 32 | testQuery() { 33 | return { id: 'hello world' }; 34 | } 35 | } 36 | 37 | @Controller('/test-case') 38 | class TestController { 39 | @Get('/happy-endpoint') 40 | get() { 41 | return { data: { message: 'I am happy' } }; 42 | } 43 | } 44 | 45 | const moduleBuilderContext = withNestModuleBuilderContext({ 46 | controllers: [TestController], 47 | imports: [], 48 | providers: [TestResolver], 49 | }); 50 | 51 | describe('General logging interceptor', () => { 52 | let app: NestExpressApplication; 53 | let logger: LoggerService; 54 | beforeEach(async () => { 55 | const module = await moduleBuilderContext.moduleBuilder.compile(); 56 | app = module.createNestApplication(); 57 | logger = { 58 | error: jest.fn(), 59 | log: jest.fn(), 60 | warn: jest.fn(), 61 | }; 62 | 63 | app.useLogger(logger); 64 | app.setViewEngine('hbs'); 65 | 66 | await startTestingServer(app); 67 | }); 68 | afterEach(async () => { 69 | await app?.close(); 70 | }); 71 | 72 | it('query graphql resolver', async () => { 73 | const server = getApolloServer(app); 74 | const UNDEFINED = gql` 75 | query Test { 76 | testQuery { 77 | id 78 | } 79 | } 80 | `; 81 | const resp = await server.executeOperation({ 82 | query: UNDEFINED, 83 | }); 84 | expect(resp.data).toBeDefined(); 85 | expect(logger.log).toHaveBeenCalled(); 86 | const logFunction = logger.log as Mock; 87 | const interceptorCall = logFunction.mock.calls.find( 88 | call => call[2] === 'GeneralLoggingInterceptor', 89 | ); 90 | expect(interceptorCall).toBeDefined(); 91 | const [loggingParams] = interceptorCall ?? []; 92 | expect(loggingParams).toStrictEqual({ 93 | duration: 0, 94 | graphql: { 95 | args: {}, 96 | info: expect.anything(), 97 | root: undefined, 98 | }, 99 | http: { 100 | method: undefined, 101 | params: undefined, 102 | query: undefined, 103 | referer: undefined, 104 | request_id: undefined, 105 | status_code: undefined, 106 | url: undefined, 107 | useragent: undefined, 108 | }, 109 | message: 'Access Log', 110 | }); 111 | }); 112 | it('query rest endpoint', async () => { 113 | await getRequestAgent(app.getHttpServer()) 114 | .get('/test-case/happy-endpoint') 115 | .expect(expectResponseCode({ expectedStatusCode: 200 })); 116 | expect(logger.log).toHaveBeenCalled(); 117 | const logFunction = logger.log as Mock; 118 | const interceptorCall = logFunction.mock.calls.find( 119 | call => call[2] === 'GeneralLoggingInterceptor', 120 | ); 121 | expect(interceptorCall).toBeDefined(); 122 | const [loggingParams] = interceptorCall ?? []; 123 | expect(loggingParams).toStrictEqual({ 124 | duration: expect.any(Number), 125 | http: { 126 | method: 'GET', 127 | params: {}, 128 | query: {}, 129 | referer: undefined, 130 | request_id: expect.any(String), 131 | status_code: 200, 132 | url: '/test-case/happy-endpoint', 133 | useragent: undefined, 134 | }, 135 | message: 'Access Log', 136 | }); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /systems/frontend/src/GameLibraryPage/GameList.provider.tsx: -------------------------------------------------------------------------------- 1 | import { ApolloError, gql, useQuery } from '@apollo/client'; 2 | import Backdrop from '@mui/material/Backdrop'; 3 | import CircularProgress from '@mui/material/CircularProgress'; 4 | import { 5 | createContext, 6 | PropsWithChildren, 7 | useCallback, 8 | useContext, 9 | useMemo, 10 | useState, 11 | } from 'react'; 12 | 13 | import type { appTheme } from '../Theme.provider'; 14 | import userId from './user'; 15 | 16 | const GET_GAME_LIST = gql` 17 | query queryGameList( 18 | $userId: ID 19 | $offset: Int 20 | $limit: Int 21 | $platform: String 22 | ) { 23 | gameList( 24 | userId: $userId 25 | offset: $offset 26 | limit: $limit 27 | platform: $platform 28 | ) { 29 | edges { 30 | node { 31 | id 32 | boxArtImageUrl 33 | platform 34 | name 35 | publisher 36 | } 37 | } 38 | pageInfo { 39 | hasNextPage 40 | } 41 | totalCount 42 | } 43 | } 44 | `; 45 | 46 | export interface Game { 47 | boxArtImageUrl: string; 48 | id: string; 49 | name: string; 50 | platform: string; 51 | publisher: string; 52 | } 53 | 54 | interface Data { 55 | gameList: { edges: { node: Game }[]; totalCount: number }; 56 | } 57 | 58 | interface TContextValue { 59 | currentPage?: number; 60 | data?: Data; 61 | error?: ApolloError; 62 | loading?: boolean; 63 | platformFilter?: string; 64 | refetch?: () => void; 65 | setFilter?: (_: unknown, platform: string) => void; 66 | setPage?: (_: unknown, page: number) => void; 67 | totalPage?: number; 68 | variables?: { 69 | offset: number; 70 | }; 71 | } 72 | 73 | const GameListContext = createContext({}); 74 | 75 | // https://www.apollographql.com/docs/react/pagination/offset-based 76 | export default function GameListProvider({ 77 | children, 78 | }: PropsWithChildren) { 79 | const [platformFilter, setPlatformFilter] = useState('ALL'); 80 | const [currentOffSet, setOffset] = useState(0); 81 | const { data, error, loading, refetch, variables } = useQuery< 82 | Data, 83 | { 84 | limit: number; 85 | offset: number; 86 | platform?: string | null; 87 | userId: string; 88 | } 89 | >(GET_GAME_LIST, { 90 | variables: { limit: 8, offset: currentOffSet, userId }, 91 | }); 92 | const setFilter = useCallback( 93 | async (_: unknown, platform: string) => { 94 | await refetch({ 95 | offset: 0, 96 | platform: platform === 'ALL' ? null : platform, 97 | }); 98 | setPlatformFilter(platform); 99 | setOffset(0); 100 | }, 101 | [refetch], 102 | ); 103 | const setPage = useCallback( 104 | async (_: unknown, page: number) => { 105 | const { limit } = variables!; 106 | const newOffset = (page - 1) * limit; 107 | await refetch({ 108 | offset: newOffset, 109 | platform: platformFilter === 'ALL' ? null : platformFilter, 110 | }); 111 | setOffset(newOffset); 112 | }, 113 | [platformFilter, refetch, variables], 114 | ); 115 | const { currentPage, totalPage } = useMemo(() => { 116 | const { limit } = variables!; 117 | const total = data?.gameList?.totalCount ?? 0; 118 | const currentPage = currentOffSet / limit + 1; 119 | const totalPage = 120 | total % limit === 0 ? total / limit : Math.floor(total / limit) + 1; 121 | return { currentPage, totalPage }; 122 | }, [currentOffSet, data?.gameList?.totalCount, variables]); 123 | return ( 124 | 138 | {children} 139 | theme.zIndex.drawer + 1, 144 | }} 145 | > 146 | 147 | 148 | 149 | ); 150 | } 151 | 152 | export function useGameList() { 153 | return useContext(GameListContext); 154 | } 155 | -------------------------------------------------------------------------------- /systems/backend/src/error-hanlding/general-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentsHost, 3 | Catch, 4 | ExceptionFilter, 5 | HttpException, 6 | Logger, 7 | } from '@nestjs/common'; 8 | import { ConfigService } from '@nestjs/config'; 9 | import { GqlArgumentsHost } from '@nestjs/graphql'; 10 | import type { ApolloError } from 'apollo-server-errors'; 11 | import type { Request, Response } from 'express'; 12 | import { omit } from 'ramda'; 13 | import { serializeError } from 'serialize-error'; 14 | 15 | import { AppEnvironment } from '../config/config.constants'; 16 | import { err } from '../logging/formats/err'; 17 | import { graphql } from '../logging/formats/graphql'; 18 | import { http } from '../logging/formats/http'; 19 | import { ApolloException } from './apollo.exception'; 20 | import { ErrorCode } from './error-code.constant'; 21 | import type { ExceptionPayload } from './exception-payload'; 22 | import { InternalServerErrorException } from './internal-server-error.exception'; 23 | 24 | @Catch() 25 | export class GeneralExceptionFilter implements ExceptionFilter { 26 | private logger = new Logger(GeneralExceptionFilter.name); 27 | 28 | constructor(private config: ConfigService) {} 29 | 30 | catch(exception: Error, host: ArgumentsHost) { 31 | const isGraphql = host.getType().toString() === 'graphql'; 32 | 33 | if (isGraphql) return this.catchGraphqlError(exception, host); 34 | return this.catchHttpError(exception, host); 35 | } 36 | 37 | private catchGraphqlError( 38 | exception: Error & { 39 | extensions?: any; 40 | response?: ExceptionPayload; 41 | }, 42 | context: ArgumentsHost, 43 | ) { 44 | const ctx = GqlArgumentsHost.create(context); 45 | 46 | const [error] = exception.response?.errors ?? []; 47 | 48 | const isApolloError = exception.extensions; 49 | 50 | const apolloError: ApolloError = ( 51 | isApolloError 52 | ? exception 53 | : new ApolloException({ 54 | code: error?.code ?? ErrorCode.UnhandledError, 55 | errors: exception.response?.errors ?? [ 56 | { detail: exception.message, title: exception.name }, 57 | ], 58 | }) 59 | ) as ApolloError; 60 | const { req, res } = ctx.getContext<{ req: Request; res: Response }>(); 61 | const end = new Date().getTime(); 62 | 63 | const { startAt = end } = res?.locals ?? {}; 64 | this.logger.error( 65 | { 66 | duration: end - startAt, 67 | err: err(apolloError), 68 | graphql: graphql(ctx), 69 | http: http(req, Object.assign(res || {}, { body: {} })), 70 | message: 'Access Log', 71 | }, 72 | exception.stack, 73 | ); 74 | return apolloError; 75 | } 76 | 77 | private catchHttpError(exception: Error, context: ArgumentsHost) { 78 | const isPrd = this.config.get('env') === AppEnvironment.PRD; 79 | const ctx = context.switchToHttp(); 80 | 81 | const request = ctx.getRequest(); 82 | 83 | const response = ctx.getResponse(); 84 | 85 | const httpException = 86 | exception instanceof HttpException 87 | ? exception 88 | : new InternalServerErrorException({ 89 | code: ErrorCode.UnhandledError, 90 | errors: [{ detail: exception.message, title: exception.name }], 91 | meta: { exception: omit(['stack'])(serializeError(exception)) }, 92 | }); 93 | httpException.stack = exception.stack as string; 94 | 95 | const stack = !isPrd ? { stack: exception.stack } : {}; 96 | 97 | const { body, status } = { 98 | body: { 99 | ...(httpException.getResponse() as Record), 100 | ...stack, 101 | }, 102 | status: httpException.getStatus(), 103 | }; 104 | const { startAt } = response.locals; 105 | const end = new Date().getTime(); 106 | // accessLog repeated here Because NestJS interceptor can't capture error throw from guard 107 | // https://stackoverflow.com/questions/61087776/interceptor-not-catching-error-thrown-by-guard-in-nestjs 108 | // https://docs.nestjs.com/faq/request-lifecycle 109 | response.status(status).json(body); 110 | 111 | this.logger.error( 112 | { 113 | duration: end - startAt, 114 | err: err(httpException), 115 | http: http(request, Object.assign(response, { body })), 116 | message: 'Access Log', 117 | }, 118 | exception.stack, 119 | ); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /systems/frontend/src/GameLibraryPage/AddGameLibrary.form.spec.tsx: -------------------------------------------------------------------------------- 1 | import { mount } from '@cypress/react'; 2 | import { useForm } from 'react-hook-form'; 3 | 4 | import GlobalContextProvider from '../GlobalContext.provider'; 5 | import AddGameLibraryForm, { 6 | GameBoxArtUploadField, 7 | } from './AddGameLibrary.form'; 8 | 9 | function uploadBoxArt() { 10 | cy.getBySel('game-box-art-upload-input').selectFile( 11 | 'cypress/fixtures/elden-ring.jpeg', 12 | { 13 | force: true, 14 | }, 15 | ); 16 | cy.getBySel('game-box-art-image').should('be.visible'); 17 | } 18 | 19 | function fillAddGameLibraryForm() { 20 | cy.getBySel('game-name-input').click().clear().type('ELDEN RING'); 21 | cy.getBySel('game-publisher-input') 22 | .clear() 23 | .type('SONY INTERACTIVE ENTERTAINMENT'); 24 | 25 | cy.getBySel('game-platform-input').click(); 26 | cy.getBySel('game-platform-input-ps5').click(); 27 | cy.getBySel('number-of-players-input').click().clear().type('1'); 28 | cy.getBySel('genre-input').click(); 29 | cy.getBySel('genre-input-action').click(); 30 | cy.getBySel('release-date-input').click().clear().type('03/24/2022'); 31 | } 32 | 33 | describe('GameBoxArtUploadField', () => { 34 | function TestGameBoxArtUploadField() { 35 | const methods = useForm({}); 36 | return ( 37 | 38 | 39 | 40 | ); 41 | } 42 | it('should upload box art', () => { 43 | mount(); 44 | uploadBoxArt(); 45 | }); 46 | }); 47 | 48 | describe('AddGameLibraryForm', () => { 49 | it('should create record on db when submit form', () => { 50 | mount( 51 | 52 | 56 | , 57 | ); 58 | uploadBoxArt(); 59 | fillAddGameLibraryForm(); 60 | cy.getBySel('submit-add-new-game-form').click(); 61 | cy.get('@finishSubmit').should('have.been.called'); 62 | cy.getBySel('created-game-id') 63 | .should('exist', { force: true }) 64 | .then(el => { 65 | const gameId = el.text(); 66 | cy.request({ 67 | method: 'GET', 68 | url: `http://localhost:5333/test/seeder/game/${gameId}`, 69 | }) 70 | .its('body.data.id') 71 | .should('equal', gameId); 72 | }); 73 | }); 74 | 75 | it('should show error alert when submit form that pass frontend validation but not backend', () => { 76 | mount( 77 | 78 | 82 | , 83 | ); 84 | uploadBoxArt(); 85 | cy.getBySel('game-name-input').click().clear().type('ELDEN RING'); 86 | cy.getBySel('game-publisher-input') 87 | .clear() 88 | .type('SONY INTERACTIVE ENTERTAINMENT'); 89 | 90 | cy.getBySel('game-platform-input').click(); 91 | cy.getBySel('game-platform-input-ps5').click(); 92 | cy.getBySel('number-of-players-input').click().clear().type('1'); 93 | cy.getBySel('genre-input').click(); 94 | cy.getBySel('genre-input-action').click(); 95 | cy.getBySel(`number-of-players-input`).click().clear().type('4'); 96 | cy.getBySel('submit-add-new-game-form').click(); 97 | cy.getBySel('alert-error-title') 98 | .should('be.visible') 99 | .should('have.text', 'BAD_USER_INPUT'); 100 | }); 101 | 102 | it('should error when number of player less than 0', () => { 103 | mount( 104 | 105 | 109 | , 110 | ); 111 | cy.getBySel(`number-of-players-input`).click().clear().type('-1'); 112 | cy.get('body').click(); 113 | cy.getBySel(`number-of-players-error`).should( 114 | 'have.text', 115 | "number of players can't less than 0", 116 | ); 117 | }); 118 | 119 | it('should error when missing box art', () => { 120 | mount( 121 | 122 | 126 | , 127 | ); 128 | fillAddGameLibraryForm(); 129 | cy.getBySel(`submit-add-new-game-form`).click(); 130 | cy.getBySel(`game-box-art-upload-error`).should( 131 | 'have.text', 132 | 'box art must be provided', 133 | ); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /systems/infrastructure/src/github-actions.ts: -------------------------------------------------------------------------------- 1 | import type * as pulumi from '@pulumi/pulumi'; 2 | import * as crypto from 'crypto'; 3 | import { promises as fs } from 'fs'; 4 | import path from 'path'; 5 | import YAML from 'yaml'; 6 | 7 | const currentDir = path.parse(new URL(import.meta.url).pathname).dir; 8 | 9 | interface GithubActionWorkflowDeploymentEnvironmentVariablesInput { 10 | API_HOST: string; 11 | ECR_IMAGE_NAME: string; 12 | ECR_REPO: string; 13 | LAMBDA_FUNCTION_ARN: string; 14 | LAMBDA_FUNCTION_LATEST_VERSION_ALIAS_ARN: string; 15 | S3_BUCKET: string; 16 | } 17 | 18 | function getFilePathOnGithubWorkflowFolder(fileName: string) { 19 | return path.join( 20 | currentDir, 21 | '..', 22 | '..', 23 | '..', 24 | '.github', 25 | 'workflows', 26 | fileName, 27 | ); 28 | } 29 | 30 | async function readFrontendDeploymentYaml() { 31 | return YAML.parse( 32 | await fs.readFile( 33 | getFilePathOnGithubWorkflowFolder('deploy-frontend.yml'), 34 | 'utf-8', 35 | ), 36 | ); 37 | } 38 | 39 | async function readBackendDeploymentYaml() { 40 | return YAML.parse( 41 | await fs.readFile( 42 | getFilePathOnGithubWorkflowFolder('deploy-backend.yml'), 43 | 'utf-8', 44 | ), 45 | ); 46 | } 47 | 48 | async function updateFrontendDeploymentYaml({ 49 | API_HOST, 50 | S3_BUCKET, 51 | }: { 52 | API_HOST: string; 53 | S3_BUCKET: string; 54 | }) { 55 | const frontendDeploymentConfig = await readFrontendDeploymentYaml(); 56 | await fs.writeFile( 57 | getFilePathOnGithubWorkflowFolder('deploy-frontend.yml'), 58 | YAML.stringify({ 59 | ...frontendDeploymentConfig, 60 | env: { 61 | ...frontendDeploymentConfig.env, 62 | API_HOST, 63 | S3_BUCKET, 64 | }, 65 | }), 66 | ); 67 | } 68 | 69 | async function updateBackendDeploymentYaml({ 70 | ECR_IMAGE_NAME, 71 | ECR_REPO, 72 | LAMBDA_FUNCTION_ARN, 73 | LAMBDA_FUNCTION_LATEST_VERSION_ALIAS_NAME, 74 | }: { 75 | ECR_IMAGE_NAME: string; 76 | ECR_REPO: string; 77 | LAMBDA_FUNCTION_ARN: string; 78 | LAMBDA_FUNCTION_LATEST_VERSION_ALIAS_NAME: string; 79 | }) { 80 | const backendDeploymentConfig = await readBackendDeploymentYaml(); 81 | await fs.writeFile( 82 | getFilePathOnGithubWorkflowFolder('deploy-backend.yml'), 83 | YAML.stringify({ 84 | ...backendDeploymentConfig, 85 | env: { 86 | ...backendDeploymentConfig.env, 87 | ECR_IMAGE_NAME, 88 | ECR_REPO, 89 | LAMBDA_FUNCTION_ARN, 90 | LAMBDA_FUNCTION_LATEST_VERSION_ALIAS_NAME, 91 | }, 92 | }), 93 | ); 94 | } 95 | 96 | const githubActionWorkFlowEnvironmentProvider: pulumi.dynamic.ResourceProvider = 97 | { 98 | async create( 99 | inputs: GithubActionWorkflowDeploymentEnvironmentVariablesInput, 100 | ) { 101 | await updateFrontendDeploymentYaml({ 102 | API_HOST: inputs.API_HOST, 103 | S3_BUCKET: inputs.S3_BUCKET, 104 | }); 105 | await updateBackendDeploymentYaml({ 106 | ECR_IMAGE_NAME: inputs.ECR_IMAGE_NAME, 107 | ECR_REPO: inputs.ECR_REPO, 108 | LAMBDA_FUNCTION_ARN: inputs.LAMBDA_FUNCTION_ARN, 109 | LAMBDA_FUNCTION_LATEST_VERSION_ALIAS_NAME: 110 | inputs.LAMBDA_FUNCTION_LATEST_VERSION_ALIAS_ARN, 111 | }); 112 | return { id: crypto.randomBytes(16).toString('hex'), outs: {} }; 113 | }, 114 | 115 | async update( 116 | _: unknown, 117 | __: unknown, 118 | inputs: GithubActionWorkflowDeploymentEnvironmentVariablesInput, 119 | ) { 120 | await this.create(inputs); 121 | return { outs: {} }; 122 | }, 123 | }; 124 | 125 | async function main() { 126 | const pulumiOutputs = JSON.parse(await fs.readFile('tmp.json', 'utf-8')); 127 | await githubActionWorkFlowEnvironmentProvider.create(pulumiOutputs); 128 | } 129 | 130 | main(); 131 | 132 | // interface GithubActionWorkflowDeploymentEnvironmentVariablesResourceInput { 133 | // API_HOST: pulumi.Input; 134 | // ECR_IMAGE_NAME: pulumi.Input; 135 | // ECR_REPO: pulumi.Input; 136 | // LAMBDA_FUNCTION_ARN: pulumi.Input; 137 | // LAMBDA_FUNCTION_LATEST_VERSION_ALIAS_ARN: pulumi.Input; 138 | // S3_BUCKET: pulumi.Input; 139 | // } 140 | 141 | // export class GithubActionWorkflowDeploymentEnvironmentVariablesResource extends pulumi 142 | // .dynamic.Resource { 143 | // constructor( 144 | // name: string, 145 | // args: GithubActionWorkflowDeploymentEnvironmentVariablesResourceInput, 146 | // opts?: pulumi.CustomResourceOptions, 147 | // ) { 148 | // super(githubActionWorkFlowEnvironmentProvider, name, args, opts); 149 | // } 150 | // } 151 | // 152 | // export function updateGithubActionWorkflowEnv( 153 | // args: GithubActionWorkflowDeploymentEnvironmentVariablesResourceInput, 154 | // ) { 155 | // const namePrefix = kebabcase(pulumi.getStack()); 156 | // 157 | // const resp = new GithubActionWorkflowDeploymentEnvironmentVariablesResource( 158 | // `${namePrefix}-deployment-environment-variables`, 159 | // args, 160 | // ); 161 | // return resp; 162 | // } 163 | -------------------------------------------------------------------------------- /systems/frontend/src/GameLibraryPage/GameLibrary.page.tsx: -------------------------------------------------------------------------------- 1 | import Box from '@mui/material/Box'; 2 | import Chip from '@mui/material/Chip'; 3 | import FormControlLabel from '@mui/material/FormControlLabel'; 4 | import Grid from '@mui/material/Grid'; 5 | import Pagination from '@mui/material/Pagination'; 6 | import Paper from '@mui/material/Paper'; 7 | import Radio from '@mui/material/Radio'; 8 | import RadioGroup from '@mui/material/RadioGroup'; 9 | import Stack from '@mui/material/Stack'; 10 | import Typography from '@mui/material/Typography'; 11 | import { useMemo } from 'react'; 12 | 13 | import { AddGameLibraryFormDialog } from './AddGameLibrary.form'; 14 | import GameListProvider, { Game, useGameList } from './GameList.provider'; 15 | 16 | function GameLibraryListItem({ game }: { game: Game }) { 17 | return ( 18 | 37 | 38 | 47 | 48 | 54 | {game.name} 55 | 56 | 62 | 63 | 64 | 65 | 70 | 77 | 78 | 79 | ); 80 | } 81 | 82 | function GameLibraryFilter() { 83 | const { platformFilter, setFilter } = useGameList(); 84 | return ( 85 | 86 | Filter 87 | 88 | } 90 | label="PS4" 91 | value="PS4" 92 | /> 93 | } 95 | label="PS5" 96 | value="PS5" 97 | /> 98 | } 100 | label="ALL" 101 | value="ALL" 102 | /> 103 | 104 | 105 | ); 106 | } 107 | 108 | function GameLibraryList() { 109 | const { currentPage, data, error, loading, setPage, totalPage } = 110 | useGameList(); 111 | 112 | const records = useMemo(() => { 113 | if (loading || error || !data) return []; 114 | return data.gameList.edges.map(edge => edge.node); 115 | }, [data, error, loading]); 116 | return ( 117 | <> 118 | 127 | {records.map(record => ( 128 | 129 | ))} 130 | 131 | 139 | 140 | 141 | 142 | ); 143 | } 144 | 145 | function GameListHeading() { 146 | const { data, error, loading, platformFilter, variables } = useGameList(); 147 | const { from, to, totalCount } = useMemo(() => { 148 | if (loading || error || !data || !variables) 149 | return { from: 0, to: 0, totalCount: 0 }; 150 | const offset = variables?.offset; 151 | const totalCount = data.gameList.totalCount; 152 | return { 153 | from: totalCount > 0 ? offset + 1 : 0, 154 | to: offset + data.gameList.edges.length, 155 | totalCount: totalCount, 156 | }; 157 | }, [data, error, loading, variables]); 158 | return ( 159 | 166 | {totalCount === 0 167 | ? `Click Add Game to your library for add record to ${platformFilter} platform` 168 | : `Saved ( ${from} - ${to} of ${totalCount}) on ${platformFilter} platform`} 169 | 170 | ); 171 | } 172 | 173 | export default function GameLibraryPage() { 174 | return ( 175 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | ); 202 | } 203 | -------------------------------------------------------------------------------- /systems/backend/src/error-hanlding/general-exception.filter.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from '@jest/globals'; 2 | import { Controller, Get, ImATeapotException } from '@nestjs/common'; 3 | import { 4 | Args, 5 | Field, 6 | ID, 7 | InputType, 8 | Mutation, 9 | ObjectType, 10 | Query, 11 | Resolver, 12 | } from '@nestjs/graphql'; 13 | import { randomUUID } from 'crypto'; 14 | import gql from 'graphql-tag'; 15 | 16 | import { expectResponseCode } from '../test-helpers/expect-response-code'; 17 | import { getApolloServer } from '../test-helpers/get-apollo-server'; 18 | import { getGraphqlErrorCodes } from '../test-helpers/get-graphql-error'; 19 | import { getRequestAgent } from '../test-helpers/get-request-agent'; 20 | import { withNestServerContext } from '../test-helpers/nest-app-context'; 21 | import { ApolloException } from './apollo.exception'; 22 | 23 | @ObjectType() 24 | class TestModel { 25 | @Field(() => ID) 26 | id!: string; 27 | } 28 | 29 | @InputType() 30 | class TestInput { 31 | @Field(() => ID) 32 | id!: string; 33 | } 34 | 35 | @Resolver(() => TestModel) 36 | class TestResolver { 37 | @Query(() => TestModel) 38 | testQuery() { 39 | throw new Error('Fake Error!!!'); 40 | } 41 | 42 | @Query(() => TestModel) 43 | testQueryWithApolloError() { 44 | throw new ApolloException({ 45 | code: 'ERR_CREATE_RECORD' as any, 46 | errors: [{ title: 'Foobar' }], 47 | }); 48 | } 49 | 50 | @Mutation(() => TestModel) 51 | testMutation(@Args('data') data: TestInput) { 52 | return { data, id: randomUUID() }; 53 | } 54 | } 55 | 56 | @Controller('/test-case') 57 | class TestController { 58 | @Get('/unexpected-error') 59 | getUnexpectedError() { 60 | throw new Error('Fake Error!!!'); 61 | } 62 | 63 | @Get('/418') 64 | get418() { 65 | throw new ImATeapotException({ 66 | code: 'ERR_TEA_POT_IS_HOT', 67 | errors: ['Foobar'], 68 | meta: {}, 69 | }); 70 | } 71 | } 72 | 73 | const appContext = withNestServerContext({ 74 | controllers: [TestController], 75 | imports: [], 76 | providers: [TestResolver], 77 | }); 78 | 79 | describe('General exception filter', () => { 80 | describe('rest', () => { 81 | it('should response code ERR_UNHANDLED when endpoint response generic error', async () => { 82 | const app = appContext.app; 83 | const { body } = await getRequestAgent(app.getHttpServer()) 84 | .get('/test-case/unexpected-error') 85 | .expect(expectResponseCode({ expectedStatusCode: 500 })); 86 | expect(body).toStrictEqual({ 87 | errors: [ 88 | { 89 | code: 'ERR_UNHANDLED', 90 | detail: 'Fake Error!!!', 91 | title: 'Error', 92 | }, 93 | ], 94 | meta: { 95 | exception: { 96 | message: 'Fake Error!!!', 97 | name: 'Error', 98 | }, 99 | }, 100 | stack: expect.any(String), 101 | }); 102 | }); 103 | it('should forward response code when endpoint have specific error code', async () => { 104 | const app = appContext.app; 105 | const { body } = await getRequestAgent(app.getHttpServer()) 106 | .get('/test-case/418') 107 | .expect(expectResponseCode({ expectedStatusCode: 418 })); 108 | expect(body).toStrictEqual({ 109 | code: 'ERR_TEA_POT_IS_HOT', 110 | errors: ['Foobar'], 111 | meta: {}, 112 | stack: expect.any(String), 113 | }); 114 | }); 115 | }); 116 | 117 | describe('graphql', () => { 118 | it('call graphql query endpoint that throw unknown error', async () => { 119 | const app = appContext.app; 120 | const server = getApolloServer(app); 121 | const UNDEFINED = gql` 122 | query Test { 123 | testQuery { 124 | id 125 | } 126 | } 127 | `; 128 | const resp = await server.executeOperation({ 129 | query: UNDEFINED, 130 | }); 131 | expect(resp.errors).toBeDefined(); 132 | expect(resp.errors).toStrictEqual([ 133 | { 134 | extensions: { 135 | code: 'ERR_UNHANDLED', 136 | errors: [ 137 | { 138 | detail: 'Fake Error!!!', 139 | title: 'Error', 140 | }, 141 | ], 142 | }, 143 | locations: [ 144 | { 145 | column: 3, 146 | line: 2, 147 | }, 148 | ], 149 | message: 'Graphql Error', 150 | path: ['testQuery'], 151 | }, 152 | ]); 153 | }); 154 | it('call graphql query endpoint that throw apollo error', async () => { 155 | const app = appContext.app; 156 | const server = getApolloServer(app); 157 | const UNDEFINED = gql` 158 | query Test { 159 | testQueryWithApolloError { 160 | id 161 | } 162 | } 163 | `; 164 | const resp = await server.executeOperation({ 165 | query: UNDEFINED, 166 | }); 167 | expect(resp.errors).toBeDefined(); 168 | expect(resp.errors).toStrictEqual([ 169 | { 170 | extensions: { 171 | code: 'ERR_CREATE_RECORD', 172 | errors: [ 173 | { 174 | title: 'Foobar', 175 | }, 176 | ], 177 | }, 178 | locations: [ 179 | { 180 | column: 3, 181 | line: 2, 182 | }, 183 | ], 184 | message: 'Graphql Error', 185 | path: ['testQueryWithApolloError'], 186 | }, 187 | ]); 188 | }); 189 | it('call graphql mutation endpoint and missing data', async () => { 190 | const app = appContext.app; 191 | const server = getApolloServer(app); 192 | const TEST_MUTATION = gql` 193 | mutation Test($data: TestInput!) { 194 | testMutation(data: $data) { 195 | id 196 | } 197 | } 198 | `; 199 | const resp = await server.executeOperation({ 200 | query: TEST_MUTATION, 201 | variables: { 202 | data: {}, 203 | }, 204 | }); 205 | expect(resp.errors).toBeDefined(); 206 | expect(getGraphqlErrorCodes(resp.errors)).toEqual(['BAD_USER_INPUT']); 207 | }); 208 | }); 209 | }); 210 | -------------------------------------------------------------------------------- /systems/backend/src/game-gallery/game-gallery.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from '@jest/globals'; 2 | import { getApolloServer } from '@nestjs/apollo'; 3 | import { randomUUID } from 'crypto'; 4 | import gql from 'graphql-tag'; 5 | 6 | import { createRequestAgent } from '../test-helpers/create-request-agent'; 7 | import { expectResponseCode } from '../test-helpers/expect-response-code'; 8 | import { withNestServerContext } from '../test-helpers/nest-app-context'; 9 | 10 | const appContext = withNestServerContext({ 11 | imports: [], 12 | }); 13 | 14 | describe('Game gallery Resolver', () => { 15 | it('upload game box art', async () => { 16 | const app = appContext.app; 17 | const server = getApolloServer(app); 18 | const PREPARE_UPLOAD_GAME_BOX_ART = gql` 19 | mutation prepareUploadGameBoxArt($fileName: String!) { 20 | prepareUploadGameBoxArt(fileName: $fileName) { 21 | id 22 | resultPublicUrl 23 | uploadUrl 24 | } 25 | } 26 | `; 27 | const resp = await server.executeOperation({ 28 | query: PREPARE_UPLOAD_GAME_BOX_ART, 29 | variables: { 30 | fileName: 'test.png', 31 | }, 32 | }); 33 | expect(resp.errors).toBeUndefined(); 34 | const { resultPublicUrl, uploadUrl } = { 35 | resultPublicUrl: new URL( 36 | resp.data?.['prepareUploadGameBoxArt']?.resultPublicUrl, 37 | ), 38 | uploadUrl: new URL(resp.data?.['prepareUploadGameBoxArt']?.uploadUrl), 39 | }; 40 | 41 | expect(`${resultPublicUrl.protocol}//${resultPublicUrl.host}`).toEqual( 42 | 'http://localhost:4566', 43 | ); 44 | expect(`${uploadUrl.protocol}//${resultPublicUrl.host}`).toEqual( 45 | 'http://localhost:4566', 46 | ); 47 | }); 48 | it('mutation addGameToLibrary', async () => { 49 | const app = appContext.app; 50 | const server = getApolloServer(app); 51 | const ADD_GAME_TO_LIST = gql` 52 | mutation addGameToLibrary($data: AddGameToLibraryArgs!) { 53 | addGameToLibrary(data: $data) { 54 | id 55 | } 56 | } 57 | `; 58 | const resp = await server.executeOperation({ 59 | query: ADD_GAME_TO_LIST, 60 | variables: { 61 | data: { 62 | boxArtImageUrl: 'https://www.google.com', 63 | genre: 'FIGHTING', 64 | name: 'GOD OF WAR', 65 | numberOfPlayers: 4, 66 | platform: 'PS4', 67 | publisher: 'SONY INTERACTIVE ENTERTAINMENT', 68 | releaseDate: '2022-03-22', 69 | userId: randomUUID(), 70 | }, 71 | }, 72 | }); 73 | expect(resp.errors).toBeUndefined(); 74 | }); 75 | 76 | it('should 200 status with error code when missing param on addGameToLibrary mutation', async () => { 77 | const app = appContext.app; 78 | const ADD_GAME_TO_LIST = ` 79 | mutation addGameToLibrary($data: AddGameToLibraryArgs!) { 80 | addGameToLibrary(data: $data) { 81 | id 82 | } 83 | } 84 | `; 85 | const { body } = await createRequestAgent(app.getHttpServer()) 86 | .post('/graphql') 87 | .send({ 88 | query: ADD_GAME_TO_LIST, 89 | variables: { 90 | data: { 91 | boxArtImageUrl: 'https://www.google.com', 92 | genre: 'FIGHTING', 93 | name: 'GOD OF WAR', 94 | numberOfPlayers: 4, 95 | platform: 'PS4', 96 | publisher: 'SONY INTERACTIVE ENTERTAINMENT', 97 | releaseDate: null, 98 | userId: randomUUID(), 99 | }, 100 | }, 101 | }) 102 | .expect(expectResponseCode({ expectedStatusCode: 200 })); 103 | 104 | expect(body.errors).toBeDefined(); 105 | expect(body.errors).toStrictEqual([ 106 | { 107 | extensions: expect.objectContaining({ 108 | code: 'BAD_USER_INPUT', 109 | }), 110 | locations: [ 111 | { 112 | column: 33, 113 | line: 2, 114 | }, 115 | ], 116 | message: 117 | 'Variable "$data" got invalid value null at "data.releaseDate"; Expected non-nullable type "Date!" not to be null.', 118 | }, 119 | ]); 120 | }); 121 | 122 | it('query gameList by platform', async () => { 123 | const app = appContext.app; 124 | 125 | const server = getApolloServer(app); 126 | const userId = randomUUID(); 127 | await createRequestAgent(app.getHttpServer()) 128 | .post('/test/seeder/game/') 129 | .send({ 130 | items: Array.from({ length: 4 }).map(() => ({ 131 | boxArtImageUrl: 'https://www.google.com', 132 | genre: 'FIGHTING', 133 | name: 'GOD OF WAR', 134 | numberOfPlayers: 4, 135 | platform: 'PS5', 136 | publisher: 'SONY INTERACTIVE ENTERTAINMENT', 137 | releaseDate: '2022-03-22', 138 | userId: userId, 139 | })), 140 | }) 141 | .expect(expectResponseCode({ expectedStatusCode: 201 })); 142 | await createRequestAgent(app.getHttpServer()) 143 | .post('/test/seeder/game/') 144 | .send({ 145 | items: Array.from({ length: 4 }).map(() => ({ 146 | boxArtImageUrl: 'https://www.google.com', 147 | genre: 'FIGHTING', 148 | name: 'GOD OF WAR', 149 | numberOfPlayers: 4, 150 | platform: 'PS4', 151 | publisher: 'SONY INTERACTIVE ENTERTAINMENT', 152 | releaseDate: '2022-03-22', 153 | userId: userId, 154 | })), 155 | }) 156 | .expect(expectResponseCode({ expectedStatusCode: 201 })); 157 | const GET_GAME_LIST = gql` 158 | query queryGameList($userId: ID, $platform: String) { 159 | gameList(userId: $userId, offset: 0, limit: 10, platform: $platform) { 160 | edges { 161 | node { 162 | id 163 | } 164 | } 165 | pageInfo { 166 | hasNextPage 167 | } 168 | totalCount 169 | } 170 | } 171 | `; 172 | const resp = await server.executeOperation({ 173 | query: GET_GAME_LIST, 174 | variables: { 175 | platform: 'PS4', 176 | userId: userId, 177 | }, 178 | }); 179 | expect(resp.errors).toBeUndefined(); 180 | const result = resp?.data?.['gameList']; 181 | expect(result.edges).toHaveLength(4); 182 | expect(result.pageInfo.hasNextPage).toBeFalsy(); 183 | expect(result.totalCount).toEqual(4); 184 | }); 185 | 186 | it('query gameList', async () => { 187 | const app = appContext.app; 188 | 189 | const server = getApolloServer(app); 190 | const userId = randomUUID(); 191 | await createRequestAgent(app.getHttpServer()) 192 | .post('/test/seeder/game/') 193 | .send({ 194 | items: Array.from({ length: 128 }).map(() => ({ 195 | boxArtImageUrl: 'https://www.google.com', 196 | genre: 'FIGHTING', 197 | name: 'GOD OF WAR', 198 | numberOfPlayers: 4, 199 | platform: 'PS4', 200 | publisher: 'SONY INTERACTIVE ENTERTAINMENT', 201 | releaseDate: '2022-03-22', 202 | userId: userId, 203 | })), 204 | }) 205 | .expect(expectResponseCode({ expectedStatusCode: 201 })); 206 | const GET_GAME_LIST = gql` 207 | query queryGameList($userId: ID) { 208 | gameList(userId: $userId, offset: 0, limit: 10) { 209 | edges { 210 | node { 211 | id 212 | } 213 | } 214 | pageInfo { 215 | hasNextPage 216 | } 217 | totalCount 218 | } 219 | } 220 | `; 221 | const resp = await server.executeOperation({ 222 | query: GET_GAME_LIST, 223 | variables: { 224 | userId: userId, 225 | }, 226 | }); 227 | expect(resp.errors).toBeUndefined(); 228 | const result = resp?.data?.['gameList']; 229 | expect(result.edges).toHaveLength(10); 230 | expect(result.pageInfo.hasNextPage).toBeTruthy(); 231 | expect(result.totalCount).toEqual(128); 232 | }); 233 | it('query game', async () => { 234 | const app = appContext.app; 235 | 236 | const server = getApolloServer(app); 237 | const userId = randomUUID(); 238 | const { 239 | body: { data: game }, 240 | } = await createRequestAgent(app.getHttpServer()) 241 | .post('/test/seeder/game/') 242 | .send({ 243 | boxArtImageUrl: 'https://www.google.com', 244 | genre: 'FIGHTING', 245 | name: 'GOD OF WAR', 246 | numberOfPlayers: 4, 247 | platform: 'PS4', 248 | publisher: 'SONY INTERACTIVE ENTERTAINMENT', 249 | releaseDate: '2022-03-22', 250 | userId: userId, 251 | }) 252 | .expect(expectResponseCode({ expectedStatusCode: 201 })); 253 | const GET_GAME = gql` 254 | query queryGame { 255 | game(id:"${game.id}") { 256 | id 257 | } 258 | } 259 | `; 260 | const resp = await server.executeOperation({ 261 | query: GET_GAME, 262 | }); 263 | expect(resp.errors).toBeUndefined(); 264 | expect(resp?.data?.['game'].id).toStrictEqual(game.id); 265 | }); 266 | }); 267 | --------------------------------------------------------------------------------