├── .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 | 
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 |
--------------------------------------------------------------------------------
/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 | 
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 | 
28 |
29 | - [Nintendo](https://store.nintendo.co.uk/en_gb/games/view-all-games/)
30 |
31 | 
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 | 
4 |
5 | 
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 |
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 |
--------------------------------------------------------------------------------