├── backend
├── public
│ ├── favicon.ico
│ ├── robots.txt
│ ├── vendor
│ │ └── telescope
│ │ │ ├── favicon.ico
│ │ │ └── mix-manifest.json
│ ├── .htaccess
│ └── web.config
├── resources
│ ├── css
│ │ └── app.css
│ └── js
│ │ ├── app.js
│ │ └── bootstrap.js
├── storage
│ ├── logs
│ │ └── .gitignore
│ ├── debugbar
│ │ └── .gitignore
│ ├── app
│ │ ├── public
│ │ │ └── .gitignore
│ │ └── .gitignore
│ └── framework
│ │ ├── sessions
│ │ └── .gitignore
│ │ ├── testing
│ │ └── .gitignore
│ │ ├── views
│ │ └── .gitignore
│ │ ├── cache
│ │ ├── data
│ │ │ └── .gitignore
│ │ └── .gitignore
│ │ └── .gitignore
├── tests
│ ├── Unit
│ │ └── .gitignore
│ ├── CreatesApplication.php
│ └── TestCase.php
├── bootstrap
│ └── cache
│ │ └── .gitignore
├── database
│ ├── .gitignore
│ ├── seeders
│ │ ├── TaskCardSeeder.php
│ │ ├── TaskListSeeder.php
│ │ ├── DatabaseSeeder.php
│ │ ├── UserSeeder.php
│ │ └── TaskBoardSeeder.php
│ ├── migrations
│ │ ├── 2014_10_12_100000_create_password_resets_table.php
│ │ ├── 2014_10_12_000000_create_users_table.php
│ │ ├── 2019_08_19_000000_create_failed_jobs_table.php
│ │ ├── 2019_12_14_000001_create_personal_access_tokens_table.php
│ │ ├── 2021_06_21_184049_add_task_list_id_columns_to_task_cards_table.php
│ │ ├── 2021_06_17_061724_create_task_boards_table.php
│ │ ├── 2021_08_03_182417_add_index_columns_to_task_boards_table.php
│ │ ├── 2014_10_12_200000_add_two_factor_columns_to_users_table.php
│ │ ├── 2021_03_14_180349_create_task_cards_table.php
│ │ └── 2021_06_17_161616_create_task_lists_table.php
│ └── factories
│ │ ├── TaskCardFactory.php
│ │ ├── TaskListFactory.php
│ │ ├── TaskBoardFactory.php
│ │ └── UserFactory.php
├── .prettierrc.json
├── docker
│ ├── 7.4
│ │ ├── php.ini
│ │ ├── start-container
│ │ └── supervisord.conf
│ ├── 8.0
│ │ ├── php.ini
│ │ ├── start-container
│ │ └── supervisord.conf
│ └── 8.1
│ │ ├── php.ini
│ │ ├── start-container
│ │ └── supervisord.conf
├── .gitattributes
├── .vscode
│ ├── settings.json
│ ├── cspell.json
│ └── extensions.json
├── docker-compose.extend.yml
├── .styleci.yml
├── .prettierignore
├── .gitignore
├── .editorconfig
├── app
│ ├── Http
│ │ ├── Middleware
│ │ │ ├── EncryptCookies.php
│ │ │ ├── VerifyCsrfToken.php
│ │ │ ├── TrustHosts.php
│ │ │ ├── PreventRequestsDuringMaintenance.php
│ │ │ ├── TrimStrings.php
│ │ │ ├── TrustProxies.php
│ │ │ ├── RedirectIfAuthenticated.php
│ │ │ └── Authenticate.php
│ │ ├── Controllers
│ │ │ ├── Controller.php
│ │ │ ├── HomeController.php
│ │ │ ├── Auth
│ │ │ │ ├── RegisteredUserController.php
│ │ │ │ └── ProfileInformationController.php
│ │ │ └── VerifyEmailController.php
│ │ ├── Responses
│ │ │ ├── VerifyEmailResponse.php
│ │ │ ├── LoginResponse.php
│ │ │ └── RegisterResponse.php
│ │ ├── Resources
│ │ │ ├── UserResource.php
│ │ │ └── TaskCardResource.php
│ │ └── Requests
│ │ │ ├── UpdateTaskListRequest.php
│ │ │ ├── StoreTaskListRequest.php
│ │ │ ├── StoreTaskBoardRequest.php
│ │ │ ├── StoreTaskCardRequest.php
│ │ │ └── UpdateTaskBoardRequest.php
│ ├── Actions
│ │ └── Fortify
│ │ │ ├── PasswordValidationRules.php
│ │ │ ├── ResetUserPassword.php
│ │ │ └── CreateNewUser.php
│ ├── Providers
│ │ ├── BroadcastServiceProvider.php
│ │ ├── AppServiceProvider.php
│ │ ├── EventServiceProvider.php
│ │ └── TelescopeServiceProvider.php
│ ├── Models
│ │ ├── TaskList.php
│ │ ├── TaskCard.php
│ │ └── TaskBoard.php
│ ├── Console
│ │ └── Kernel.php
│ └── Jobs
│ │ └── DeleteUnverifiedUser.php
├── lang
│ ├── ja
│ │ ├── pagination.php
│ │ ├── auth.php
│ │ └── passwords.php
│ └── en
│ │ ├── pagination.php
│ │ ├── auth.php
│ │ └── passwords.php
├── routes
│ ├── web.php
│ ├── channels.php
│ ├── console.php
│ ├── api.php
│ └── api
│ │ └── v1.php
├── webpack.mix.js
├── server.php
├── .devcontainer
│ └── devcontainer.json
├── package.json
├── config
│ ├── cors.php
│ ├── services.php
│ └── view.php
├── .env.dev
├── .env.testing
├── phpunit.xml
└── .env.example
├── frontend
├── .prettierrc.json
├── src
│ ├── react-app-env.d.ts
│ ├── mocks
│ │ ├── handlers
│ │ │ ├── types
│ │ │ │ ├── index.ts
│ │ │ │ └── ErrorResponse.ts
│ │ │ ├── utils
│ │ │ │ ├── index.ts
│ │ │ │ └── returnInvalidRequest.ts
│ │ │ └── index.ts
│ │ ├── utils
│ │ │ ├── uuid.ts
│ │ │ ├── store
│ │ │ │ ├── boards.ts
│ │ │ │ └── auth.ts
│ │ │ ├── route.ts
│ │ │ └── crypto.ts
│ │ ├── data
│ │ │ └── index.ts
│ │ ├── server.ts
│ │ ├── browser.ts
│ │ ├── controllers
│ │ │ ├── auth
│ │ │ │ ├── deleteAccount.ts
│ │ │ │ ├── createUser.ts
│ │ │ │ ├── resetPassword.ts
│ │ │ │ ├── updatePassword.ts
│ │ │ │ └── updateProfile.ts
│ │ │ ├── index.ts
│ │ │ ├── taskListController.ts
│ │ │ └── taskCardController.ts
│ │ ├── models
│ │ │ ├── taskBoard.ts
│ │ │ ├── taskList.ts
│ │ │ ├── taskCard.ts
│ │ │ ├── user.ts
│ │ │ └── auth.ts
│ │ └── store.ts
│ ├── store
│ │ ├── thunks
│ │ │ ├── config
│ │ │ │ ├── index.ts
│ │ │ │ └── AsyncThunkConfig.ts
│ │ │ ├── utils
│ │ │ │ ├── index.ts
│ │ │ │ └── makeRejectValue.ts
│ │ │ ├── lists
│ │ │ │ ├── index.ts
│ │ │ │ ├── createTaskList.ts
│ │ │ │ ├── updateTaskList.ts
│ │ │ │ └── destroyTaskList.ts
│ │ │ ├── cards
│ │ │ │ ├── index.ts
│ │ │ │ ├── createTaskCard.ts
│ │ │ │ ├── updateTaskCardRelationships.ts
│ │ │ │ ├── destroyTaskCard.ts
│ │ │ │ └── updateTaskCard.ts
│ │ │ ├── boards
│ │ │ │ ├── index.ts
│ │ │ │ ├── fetchTaskBoard.ts
│ │ │ │ ├── createTaskBoard.ts
│ │ │ │ ├── fetchTaskBoards.ts
│ │ │ │ ├── updateTaskBoard.ts
│ │ │ │ └── destroyTaskBoard.ts
│ │ │ └── auth
│ │ │ │ ├── index.ts
│ │ │ │ ├── signOutFromAPI.ts
│ │ │ │ ├── deleteAccount.ts
│ │ │ │ ├── updatePassword.ts
│ │ │ │ ├── sendEmailVerificationLink.ts
│ │ │ │ ├── fetchAuthUser.ts
│ │ │ │ ├── verifyEmail.ts
│ │ │ │ ├── forgotPassword.ts
│ │ │ │ ├── updateProfile.ts
│ │ │ │ ├── resetPassword.ts
│ │ │ │ ├── signInWithEmail.ts
│ │ │ │ └── createUser.ts
│ │ ├── slices
│ │ │ ├── index.ts
│ │ │ └── appSlice.ts
│ │ └── index.ts
│ ├── pages
│ │ ├── error
│ │ │ ├── index.ts
│ │ │ └── NotFound.tsx
│ │ ├── static
│ │ │ └── index.ts
│ │ ├── boards
│ │ │ └── index.ts
│ │ ├── auth
│ │ │ ├── index.ts
│ │ │ └── VerifyEmail.tsx
│ │ └── index.tsx
│ ├── utils
│ │ ├── api
│ │ │ ├── index.ts
│ │ │ ├── errors
│ │ │ │ ├── index.ts
│ │ │ │ ├── isHttpException.ts
│ │ │ │ ├── makeErrorMessageFrom.ts
│ │ │ │ └── isInvalidRequest.ts
│ │ │ └── makePath.ts
│ │ ├── generator
│ │ │ ├── index.ts
│ │ │ ├── makeEmail.ts
│ │ │ └── generateRandomString.ts
│ │ ├── hooks
│ │ │ ├── useQuery.ts
│ │ │ ├── index.ts
│ │ │ ├── useAppDispatch.ts
│ │ │ ├── useAppSelector.ts
│ │ │ ├── usePrevious.ts
│ │ │ ├── useAppLocation.ts
│ │ │ └── useDeepEqualSelector.ts
│ │ ├── dnd.ts
│ │ ├── sort.ts
│ │ ├── infoBox.ts
│ │ └── auth.ts
│ ├── components
│ │ ├── boards
│ │ │ ├── TaskCard
│ │ │ │ └── index.ts
│ │ │ ├── TaskBoard
│ │ │ │ └── index.ts
│ │ │ ├── TaskList
│ │ │ │ └── index.ts
│ │ │ ├── InfoBox
│ │ │ │ └── index.ts
│ │ │ └── index.ts
│ │ ├── layouts
│ │ │ ├── Sidebar
│ │ │ │ └── index.ts
│ │ │ └── Header
│ │ │ │ ├── index.ts
│ │ │ │ └── AccountMenuList.tsx
│ │ ├── home
│ │ │ └── LandingPage
│ │ │ │ └── index.ts
│ │ └── account
│ │ │ ├── index.ts
│ │ │ └── UserStatus.tsx
│ ├── models
│ │ ├── User.ts
│ │ ├── index.ts
│ │ └── Task.ts
│ ├── config
│ │ ├── moment.ts
│ │ ├── app.ts
│ │ ├── mdEditor.ts
│ │ └── api.ts
│ ├── theme
│ │ ├── index.ts
│ │ ├── palette.ts
│ │ ├── typography.ts
│ │ └── overrides.ts
│ ├── layouts
│ │ ├── StandbyScreen.tsx
│ │ ├── index.ts
│ │ ├── BaseLayout.tsx
│ │ ├── Loading.tsx
│ │ ├── Progressbar.tsx
│ │ ├── FlashNotification.tsx
│ │ ├── Footer.tsx
│ │ └── MarkdownWithToc.tsx
│ ├── reportWebVitals.ts
│ ├── templates
│ │ ├── SubmitButton.tsx
│ │ ├── ScrolledDiv.tsx
│ │ ├── ScrolledTypography.tsx
│ │ ├── AlertMessage.tsx
│ │ ├── LinkWrapper.tsx
│ │ ├── ScrolledGridContainer.tsx
│ │ ├── index.ts
│ │ ├── LabeledCheckbox.tsx
│ │ ├── LinkButton.tsx
│ │ ├── Markdown.tsx
│ │ └── DatetimeInput.tsx
│ ├── setupTests.ts
│ ├── App.tsx
│ └── __tests__
│ │ └── store
│ │ └── auth
│ │ └── thunks
│ │ └── forgotPassoword.test.ts
├── public
│ ├── robots.txt
│ ├── favicon.ico
│ ├── logo192.png
│ ├── manifest.json
│ └── index.html
├── .eslintignore
├── config-overrides.js
├── .lintstagedrc.yml
├── .prettierignore
├── .vscode
│ ├── settings.json
│ └── extensions.json
├── .husky
│ └── pre-commit
├── .gitignore
├── tsconfig.json
├── .babelrc.js
└── .env.example
└── terraform
├── versions.tf
├── backend.dev.hcl
├── acm.tf
├── main.tf
├── s3.tf
├── .gitignore
├── route53.tf
└── terraform.tfvars.example
/backend/public/favicon.ico:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/resources/css/app.css:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/storage/logs/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 |
--------------------------------------------------------------------------------
/backend/tests/Unit/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 |
--------------------------------------------------------------------------------
/backend/bootstrap/cache/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 |
--------------------------------------------------------------------------------
/backend/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow:
3 |
--------------------------------------------------------------------------------
/backend/resources/js/app.js:
--------------------------------------------------------------------------------
1 | require('./bootstrap');
2 |
--------------------------------------------------------------------------------
/backend/storage/debugbar/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 |
--------------------------------------------------------------------------------
/backend/database/.gitignore:
--------------------------------------------------------------------------------
1 | *.sqlite
2 | *.sqlite-journal
3 |
--------------------------------------------------------------------------------
/backend/storage/app/public/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 |
--------------------------------------------------------------------------------
/frontend/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true
3 | }
4 |
--------------------------------------------------------------------------------
/backend/storage/app/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !public/
3 | !.gitignore
4 |
--------------------------------------------------------------------------------
/backend/storage/framework/sessions/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 |
--------------------------------------------------------------------------------
/backend/storage/framework/testing/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 |
--------------------------------------------------------------------------------
/backend/storage/framework/views/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 |
--------------------------------------------------------------------------------
/backend/storage/framework/cache/data/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 |
--------------------------------------------------------------------------------
/backend/storage/framework/cache/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !data/
3 | !.gitignore
4 |
--------------------------------------------------------------------------------
/frontend/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/frontend/src/mocks/handlers/types/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ErrorResponse';
2 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/config/index.ts:
--------------------------------------------------------------------------------
1 | export * from './AsyncThunkConfig';
2 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './makeRejectValue';
2 |
--------------------------------------------------------------------------------
/frontend/src/pages/error/index.ts:
--------------------------------------------------------------------------------
1 | export { default as NotFound } from './NotFound';
2 |
--------------------------------------------------------------------------------
/backend/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "phpVersion": "8.1"
4 | }
5 |
--------------------------------------------------------------------------------
/frontend/src/utils/api/index.ts:
--------------------------------------------------------------------------------
1 | export * from './apiClient';
2 | export * from './makePath';
3 |
--------------------------------------------------------------------------------
/frontend/src/components/boards/TaskCard/index.ts:
--------------------------------------------------------------------------------
1 | export { default as TaskCard } from './TaskCard';
2 |
--------------------------------------------------------------------------------
/frontend/src/components/layouts/Sidebar/index.ts:
--------------------------------------------------------------------------------
1 | export { default as SideMenu } from './SideMenu';
2 |
--------------------------------------------------------------------------------
/frontend/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/frontend/.eslintignore:
--------------------------------------------------------------------------------
1 | # In accordance with `.gitignore`,
2 |
3 | node_modules
4 | build
5 | coverage
6 | public
7 |
--------------------------------------------------------------------------------
/frontend/src/components/layouts/Header/index.ts:
--------------------------------------------------------------------------------
1 | export { default as AccountMenuList } from './AccountMenuList';
2 |
--------------------------------------------------------------------------------
/frontend/src/utils/generator/index.ts:
--------------------------------------------------------------------------------
1 | export * from './generateRandomString';
2 | export * from './makeEmail';
3 |
--------------------------------------------------------------------------------
/backend/docker/7.4/php.ini:
--------------------------------------------------------------------------------
1 | [PHP]
2 | post_max_size = 100M
3 | upload_max_filesize = 100M
4 | variables_order = EGPCS
5 |
--------------------------------------------------------------------------------
/backend/docker/8.0/php.ini:
--------------------------------------------------------------------------------
1 | [PHP]
2 | post_max_size = 100M
3 | upload_max_filesize = 100M
4 | variables_order = EGPCS
5 |
--------------------------------------------------------------------------------
/backend/docker/8.1/php.ini:
--------------------------------------------------------------------------------
1 | [PHP]
2 | post_max_size = 100M
3 | upload_max_filesize = 100M
4 | variables_order = EGPCS
5 |
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zuka-e/laravel-react-task-spa/HEAD/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zuka-e/laravel-react-task-spa/HEAD/frontend/public/logo192.png
--------------------------------------------------------------------------------
/frontend/src/mocks/utils/uuid.ts:
--------------------------------------------------------------------------------
1 | import { datatype } from 'faker';
2 |
3 | export const uuid = () => datatype.uuid();
4 |
--------------------------------------------------------------------------------
/frontend/src/mocks/handlers/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './applyMiddleware';
2 | export * from './returnInvalidRequest';
3 |
--------------------------------------------------------------------------------
/frontend/src/pages/static/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Privacy } from './Privacy';
2 | export { default as Terms } from './Terms';
3 |
--------------------------------------------------------------------------------
/frontend/src/store/slices/index.ts:
--------------------------------------------------------------------------------
1 | export * from './appSlice';
2 | export * from './authSlice';
3 | export * from './taskBoardSlice';
4 |
--------------------------------------------------------------------------------
/frontend/config-overrides.js:
--------------------------------------------------------------------------------
1 | const { useBabelRc, override } = require('customize-cra');
2 |
3 | module.exports = override(useBabelRc());
4 |
--------------------------------------------------------------------------------
/frontend/src/pages/boards/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Index } from './TaskBoardIndex';
2 | export { default as Show } from './TaskBoard';
3 |
--------------------------------------------------------------------------------
/backend/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto
2 | *.css linguist-vendored
3 | *.scss linguist-vendored
4 | *.js linguist-vendored
5 | CHANGELOG.md export-ignore
6 |
--------------------------------------------------------------------------------
/backend/public/vendor/telescope/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zuka-e/laravel-react-task-spa/HEAD/backend/public/vendor/telescope/favicon.ico
--------------------------------------------------------------------------------
/frontend/src/components/home/LandingPage/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Hero } from './Hero';
2 | export { default as Features } from './Features';
3 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/lists/index.ts:
--------------------------------------------------------------------------------
1 | export * from './createTaskList';
2 | export * from './updateTaskList';
3 | export * from './destroyTaskList';
4 |
--------------------------------------------------------------------------------
/frontend/src/mocks/data/index.ts:
--------------------------------------------------------------------------------
1 | export * from './users';
2 | export * from './taskBoards';
3 | export * from './taskLists';
4 | export * from './taskCards';
5 |
--------------------------------------------------------------------------------
/frontend/src/utils/api/errors/index.ts:
--------------------------------------------------------------------------------
1 | export * from './isHttpException';
2 | export * from './isInvalidRequest';
3 | export * from './makeErrorMessageFrom';
4 |
--------------------------------------------------------------------------------
/frontend/.lintstagedrc.yml:
--------------------------------------------------------------------------------
1 | # https://github.com/okonet/lint-staged#configuration
2 | '*.{js,jsx,ts,tsx}':
3 | - 'eslint --max-warnings=0'
4 | - 'prettier --write'
5 |
--------------------------------------------------------------------------------
/frontend/src/components/boards/TaskBoard/index.ts:
--------------------------------------------------------------------------------
1 | export { default as BoardCardHeader } from './BoardCardHeader';
2 | export { default as BoardMenu } from './BoardMenu';
3 |
--------------------------------------------------------------------------------
/terraform/versions.tf:
--------------------------------------------------------------------------------
1 | terraform {
2 | required_providers {
3 | aws = {
4 | source = "hashicorp/aws"
5 | version = ">= 4.16.0"
6 | }
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/backend/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.defaultFormatter": "esbenp.prettier-vscode",
3 | "editor.formatOnSave": true,
4 | "files.associations": { "*.module": "php" }
5 | }
6 |
--------------------------------------------------------------------------------
/terraform/backend.dev.hcl:
--------------------------------------------------------------------------------
1 | # https://www.terraform.io/docs/language/settings/backends/remote.html#using-cli-input
2 |
3 | organization = "miwataru"
4 |
5 | workspaces {
6 | name = "dev"
7 | }
8 |
--------------------------------------------------------------------------------
/backend/storage/framework/.gitignore:
--------------------------------------------------------------------------------
1 | compiled.php
2 | config.php
3 | down
4 | events.scanned.php
5 | maintenance.php
6 | routes.php
7 | routes.scanned.php
8 | schedule-*
9 | services.json
10 |
--------------------------------------------------------------------------------
/frontend/src/models/User.ts:
--------------------------------------------------------------------------------
1 | export type User = {
2 | id: string;
3 | name: string;
4 | email: string;
5 | emailVerifiedAt: Date | null;
6 | createdAt: Date;
7 | updatedAt: Date;
8 | };
9 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/cards/index.ts:
--------------------------------------------------------------------------------
1 | export * from './createTaskCard';
2 | export * from './updateTaskCard';
3 | export * from './updateTaskCardRelationships';
4 | export * from './destroyTaskCard';
5 |
--------------------------------------------------------------------------------
/frontend/.prettierignore:
--------------------------------------------------------------------------------
1 | # In accordance with `.gitignore` and `.eslintignore`,
2 |
3 | node_modules
4 | build
5 | coverage
6 | public
7 | package.json
8 | package-lock.json
9 | yarn.lock
10 | *.md
11 |
--------------------------------------------------------------------------------
/frontend/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | // https://code.visualstudio.com/docs/editor/workspaces#_workspace-settings
2 | {
3 | "editor.defaultFormatter": "esbenp.prettier-vscode",
4 | "editor.formatOnSave": true
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/src/components/boards/TaskList/index.ts:
--------------------------------------------------------------------------------
1 | export { default as TaskList } from './TaskList';
2 | export { default as ListCardHeader } from './ListCardHeader';
3 | export { default as ListMenu } from './ListMenu';
4 |
--------------------------------------------------------------------------------
/frontend/src/config/moment.ts:
--------------------------------------------------------------------------------
1 | import moment from 'moment';
2 | import 'moment/locale/ja';
3 |
4 | moment.updateLocale('ja', {
5 | calendar: { sameElse: 'YYYY年MM月DD日 dddd HH:mm' },
6 | invalidDate: '日付が表示できません',
7 | });
8 |
--------------------------------------------------------------------------------
/frontend/src/utils/hooks/useQuery.ts:
--------------------------------------------------------------------------------
1 | import { useLocation } from 'react-router-dom';
2 |
3 | // クエリパラメータ用カスタムフック
4 | export const useQuery = () => new URLSearchParams(useLocation().search);
5 |
6 | export default useQuery;
7 |
--------------------------------------------------------------------------------
/backend/public/vendor/telescope/mix-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "/app.js": "/app.js?id=784d14e9c5825eae73d9",
3 | "/app.css": "/app.css?id=428958f8fb1feeff0a98",
4 | "/app-dark.css": "/app-dark.css?id=57a25a32a7efccdf2de0"
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/src/mocks/server.ts:
--------------------------------------------------------------------------------
1 | import { setupServer } from 'msw/node';
2 |
3 | import { handlers } from './handlers';
4 |
5 | // Setup requests interception using the given handlers.
6 | export const server = setupServer(...handlers);
7 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/boards/index.ts:
--------------------------------------------------------------------------------
1 | export * from './createTaskBoard';
2 | export * from './fetchTaskBoards';
3 | export * from './fetchTaskBoard';
4 | export * from './updateTaskBoard';
5 | export * from './destroyTaskBoard';
6 |
--------------------------------------------------------------------------------
/backend/docker-compose.extend.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | laravel.test:
4 | volumes:
5 | - '..:${PROJECT_DIR}'
6 | - 'vscode-extensions:/home/sail/.vscode-server/extensions'
7 | volumes:
8 | vscode-extensions:
9 |
--------------------------------------------------------------------------------
/frontend/src/mocks/browser.ts:
--------------------------------------------------------------------------------
1 | import { setupWorker } from 'msw';
2 | import { handlers } from './handlers';
3 |
4 | // This configures a Service Worker with the given request handlers.
5 | export const worker = setupWorker(...handlers);
6 |
--------------------------------------------------------------------------------
/frontend/src/mocks/utils/store/boards.ts:
--------------------------------------------------------------------------------
1 | import { EnhancedStore } from '@reduxjs/toolkit';
2 |
3 | import { RootState } from 'store';
4 |
5 | export const isLoading = (store: EnhancedStore) =>
6 | store.getState().boards.loading;
7 |
--------------------------------------------------------------------------------
/frontend/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | # https://typicode.github.io/husky/#/?id=custom-directory
5 | cd frontend
6 | # https://github.com/okonet/lint-staged#command-line-flags
7 | npx lint-staged
8 |
--------------------------------------------------------------------------------
/backend/.styleci.yml:
--------------------------------------------------------------------------------
1 | php:
2 | preset: laravel
3 | disabled:
4 | - no_unused_imports
5 | finder:
6 | not-name:
7 | - index.php
8 | - server.php
9 | js:
10 | finder:
11 | not-name:
12 | - webpack.mix.js
13 | css: true
14 |
--------------------------------------------------------------------------------
/frontend/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | // https://code.visualstudio.com/docs/editor/extension-marketplace#_workspace-recommended-extensions
2 | {
3 | "recommendations": [
4 | "dbaeumer.vscode-eslint", // ESLint
5 | "esbenp.prettier-vscode" // Prettier
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/frontend/src/components/account/index.ts:
--------------------------------------------------------------------------------
1 | export { default as DeleteAccountDialog } from './DeleteAccountDialog';
2 | export { default as Password } from './Password';
3 | export { default as UserProfile } from './UserProfile';
4 | export { default as UserStatus } from './UserStatus';
5 |
--------------------------------------------------------------------------------
/frontend/src/utils/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from './usePrevious';
2 | export * from './useAppDispatch';
3 | export { default as useAppLocation } from './useAppLocation';
4 | export * from './useAppSelector';
5 | export * from './useDeepEqualSelector';
6 | export * from './useQuery';
7 |
--------------------------------------------------------------------------------
/frontend/src/mocks/handlers/types/ErrorResponse.ts:
--------------------------------------------------------------------------------
1 | import { HttpException, InvalidRequest } from 'utils/api/errors';
2 |
3 | export type ErrorResponse = {
4 | message?: HttpException['response']['data']['message'];
5 | errors?: InvalidRequest['response']['data']['errors'];
6 | };
7 |
--------------------------------------------------------------------------------
/backend/.prettierignore:
--------------------------------------------------------------------------------
1 | # https://prettier.io/docs/en/ignore.html
2 |
3 | public
4 | storage
5 |
6 | vendor
7 | node_modules
8 |
9 | composer.json
10 | composer.lock
11 | package.json
12 | package-lock.json
13 |
14 | _ide_helper.php
15 | _ide_helper_models.php
16 |
17 | *.md
18 |
--------------------------------------------------------------------------------
/frontend/src/components/boards/InfoBox/index.ts:
--------------------------------------------------------------------------------
1 | export { default as InfoBox } from './InfoBox';
2 | export { default as TaskBoardDetails } from './TaskBoardDetails';
3 | export { default as TaskListDetails } from './TaskListDetails';
4 | export { default as TaskCardDetails } from './TaskCardDetails';
5 |
--------------------------------------------------------------------------------
/frontend/src/utils/generator/makeEmail.ts:
--------------------------------------------------------------------------------
1 | import { generateRandomString } from '.';
2 |
3 | export const makeEmail = () => {
4 | const username = generateRandomString() + generateRandomString();
5 | const domain = 'example.com';
6 | const email = username + '@' + domain;
7 | return email;
8 | };
9 |
--------------------------------------------------------------------------------
/frontend/src/utils/hooks/useAppDispatch.ts:
--------------------------------------------------------------------------------
1 | import { useDispatch } from 'react-redux';
2 |
3 | import type { AppDispatch } from 'store';
4 |
5 | // `useDispatch`使用時、'middleware'(Redux Thunkを含む)を適用する
6 | export const useAppDispatch = () => useDispatch();
7 |
8 | export default useAppDispatch;
9 |
--------------------------------------------------------------------------------
/backend/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /public/hot
3 | /public/storage
4 | /storage/*.key
5 | /vendor
6 | .env
7 | .env.backup
8 | .phpunit.result.cache
9 | docker-compose.override.yml
10 | Homestead.json
11 | Homestead.yaml
12 | npm-debug.log
13 | yarn-error.log
14 | .serverless/
15 | .env.production
16 |
--------------------------------------------------------------------------------
/frontend/src/mocks/controllers/auth/deleteAccount.ts:
--------------------------------------------------------------------------------
1 | import { auth, db, UserDocument } from 'mocks/models';
2 |
3 | export const remove = (currentUser: UserDocument) => {
4 | if (!db.remove('users', currentUser.id))
5 | throw new Error('The Account failed to be deleted');
6 |
7 | auth.logout();
8 | };
9 |
--------------------------------------------------------------------------------
/frontend/src/mocks/models/taskBoard.ts:
--------------------------------------------------------------------------------
1 | import { CollectionBase, DocumentBase } from 'models';
2 |
3 | export type TaskBoardDocument = {
4 | userId: string;
5 | title: string;
6 | description: string;
7 | } & DocumentBase;
8 |
9 | export type TaskBoardsCollection = CollectionBase;
10 |
--------------------------------------------------------------------------------
/frontend/src/utils/hooks/useAppSelector.ts:
--------------------------------------------------------------------------------
1 | import { TypedUseSelectorHook, useSelector } from 'react-redux';
2 |
3 | import { RootState } from 'store';
4 |
5 | // `useSelector`使用時、`(state: RootState)`を毎回入力する必要をなくす
6 | export const useAppSelector: TypedUseSelectorHook = useSelector;
7 |
8 | export default useAppSelector;
9 |
--------------------------------------------------------------------------------
/backend/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | insert_final_newline = true
7 | indent_style = space
8 | indent_size = 4
9 | trim_trailing_whitespace = true
10 |
11 | [*.md]
12 | trim_trailing_whitespace = false
13 |
14 | [*.{js,jsx,ts,tsx,vue,json,yml,yaml}]
15 | indent_size = 2
16 |
--------------------------------------------------------------------------------
/frontend/src/mocks/models/taskList.ts:
--------------------------------------------------------------------------------
1 | import { CollectionBase, DocumentBase } from 'models';
2 |
3 | export type TaskListDocument = {
4 | userId: string;
5 | boardId: string;
6 | title: string;
7 | description: string;
8 | } & DocumentBase;
9 |
10 | export type TaskListsCollection = CollectionBase;
11 |
--------------------------------------------------------------------------------
/frontend/src/theme/index.ts:
--------------------------------------------------------------------------------
1 | import { createTheme } from '@material-ui/core/styles';
2 |
3 | import palette from './palette';
4 | import typography from './typography';
5 | import overrides from './overrides';
6 |
7 | const theme = createTheme({
8 | palette,
9 | typography,
10 | overrides,
11 | });
12 |
13 | export default theme;
14 |
--------------------------------------------------------------------------------
/frontend/src/mocks/utils/route.ts:
--------------------------------------------------------------------------------
1 | import { API_HOST, API_ROUTE, GET_CSRF_TOKEN_PATH, paths } from 'config/api';
2 |
3 | export const url = (pathName: keyof typeof paths) => {
4 | const path = paths[pathName];
5 | const nonApiRouteList = [GET_CSRF_TOKEN_PATH];
6 | return nonApiRouteList.includes(path) ? API_HOST + path : API_ROUTE + path;
7 | };
8 |
--------------------------------------------------------------------------------
/frontend/src/utils/hooks/usePrevious.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 |
3 | /**
4 | * 直前の情報(`ref`)を保持
5 | */
6 | export const usePrevious = (initialState: T) => {
7 | const ref = useRef();
8 |
9 | useEffect(() => {
10 | ref.current = initialState;
11 | }, [initialState]);
12 |
13 | return ref.current;
14 | };
15 |
--------------------------------------------------------------------------------
/backend/.vscode/cspell.json:
--------------------------------------------------------------------------------
1 | {
2 | // Version of the setting file. Always 0.2
3 | "version": "0.2",
4 | // language - current active spelling language
5 | "language": "en",
6 | // words - list of words to be always considered correct
7 | "words": [
8 | "bref",
9 | "Dotenv",
10 | "Laravel",
11 | "SJIS",
12 | "upsert"
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/frontend/src/mocks/models/taskCard.ts:
--------------------------------------------------------------------------------
1 | import { CollectionBase, DocumentBase } from 'models';
2 |
3 | export type TaskCardDocument = {
4 | userId: string;
5 | listId: string;
6 | title: string;
7 | content: string;
8 | deadline: Date;
9 | done: boolean;
10 | } & DocumentBase;
11 |
12 | export type TaskCardsCollection = CollectionBase;
13 |
--------------------------------------------------------------------------------
/frontend/src/config/app.ts:
--------------------------------------------------------------------------------
1 | export const APP_NAME = process.env.REACT_APP_APP_NAME || '';
2 |
3 | export const GUEST_NAME = process.env.REACT_APP_GUEST_NAME || '';
4 | export const GUEST_EMAIL = process.env.REACT_APP_GUEST_EMAIL || '';
5 | export const GUEST_PASSWORD = process.env.REACT_APP_GUEST_PASSWORD || '';
6 |
7 | if (!APP_NAME) throw Error('environment variables required');
8 |
--------------------------------------------------------------------------------
/frontend/src/layouts/StandbyScreen.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Header, Footer, Progressbar } from 'layouts';
4 |
5 | const StandbyScreen: React.FC = () => {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 | );
13 | };
14 |
15 | export default StandbyScreen;
16 |
--------------------------------------------------------------------------------
/frontend/src/utils/hooks/useAppLocation.ts:
--------------------------------------------------------------------------------
1 | import type { Location } from 'history';
2 | import { useLocation } from 'react-router-dom';
3 |
4 | type LocationState = {
5 | /** Previously accessed page information */
6 | from: Location;
7 | };
8 |
9 | const useAppLocation = () => useLocation();
10 |
11 | export default useAppLocation;
12 |
--------------------------------------------------------------------------------
/frontend/src/components/boards/index.ts:
--------------------------------------------------------------------------------
1 | export { default as ButtonToAddTask } from './ButtonToAddTask';
2 | export { default as TitleForm } from './TitleForm';
3 | export { default as EditableTitle } from './EditableTitle';
4 | export { default as SortSelect } from './SortSelect';
5 | export { default as SearchField } from './SearchField';
6 | export { default as SearchResult } from './SearchResult';
7 |
--------------------------------------------------------------------------------
/backend/docker/7.4/start-container:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | if [ ! -z "$WWWUSER" ]; then
4 | usermod -u $WWWUSER sail
5 | fi
6 |
7 | if [ ! -d /.composer ]; then
8 | mkdir /.composer
9 | fi
10 |
11 | chmod -R ugo+rw /.composer
12 |
13 | if [ $# -gt 0 ];then
14 | exec gosu $WWWUSER "$@"
15 | else
16 | /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
17 | fi
18 |
--------------------------------------------------------------------------------
/frontend/src/mocks/handlers/utils/returnInvalidRequest.ts:
--------------------------------------------------------------------------------
1 | import { compose, context } from 'msw';
2 |
3 | import type { ErrorResponse } from 'mocks/handlers/types';
4 |
5 | export const returnInvalidRequest = (errors: ErrorResponse['errors']) =>
6 | compose(
7 | context.status(422),
8 | context.json({
9 | message: 'Invalid request.',
10 | errors,
11 | })
12 | );
13 |
--------------------------------------------------------------------------------
/backend/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | // https://code.visualstudio.com/docs/editor/extension-marketplace#_workspace-recommended-extensions
2 | {
3 | "recommendations": [
4 | "bmewburn.vscode-intelephense-client", // PHP intellisense
5 | "amiralizadeh9480.laravel-extra-intellisense", // Laravel intellisense
6 | "dbaeumer.vscode-eslint", // ESLint
7 | "esbenp.prettier-vscode" // Prettier
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/backend/app/Http/Middleware/EncryptCookies.php:
--------------------------------------------------------------------------------
1 | {
5 | [uuid: string]: T;
6 | }
7 |
8 | /**
9 | * `extends`した`interface`及び交差させた`type`に必須プロパティを付与
10 | */
11 | export interface DocumentBase {
12 | id: string;
13 | createdAt: Date;
14 | updatedAt: Date;
15 | index?: number;
16 | }
17 |
18 | export * from './Task';
19 |
--------------------------------------------------------------------------------
/backend/app/Http/Middleware/VerifyCsrfToken.php:
--------------------------------------------------------------------------------
1 | ;
7 | }
8 |
9 | export const isHttpException = (payload: unknown): payload is HttpException =>
10 | axios.isAxiosError(payload) &&
11 | typeof payload.response?.data?.message === 'string';
12 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/frontend/src/pages/auth/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Account } from './Account';
2 | export { default as EmailVerification } from './EmailVerification';
3 | export { default as ForgotPassword } from './ForgotPassword';
4 | export { default as ResetPassword } from './ResetPassword';
5 | export { default as SignIn } from './SignIn';
6 | export { default as SignUp } from './SignUp';
7 | export { default as VerifyEmail } from './VerifyEmail';
8 |
--------------------------------------------------------------------------------
/frontend/src/utils/api/errors/makeErrorMessageFrom.ts:
--------------------------------------------------------------------------------
1 | import { InvalidRequest } from '.';
2 |
3 | export const makeErrorMessageFrom = (error: InvalidRequest) => {
4 | const concatenateErrorsWithLineBreaks = (message: string, errors: string[]) =>
5 | message + errors.join('\n') + '\n';
6 |
7 | return Object.values(error.response.data.errors).reduce(
8 | concatenateErrorsWithLineBreaks,
9 | ''
10 | );
11 | };
12 |
--------------------------------------------------------------------------------
/frontend/src/mocks/handlers/index.ts:
--------------------------------------------------------------------------------
1 | import * as auth from './auth';
2 | import * as boards from './boards';
3 | import * as lists from './lists';
4 | import * as cards from './cards';
5 |
6 | // HTTPメソッドとリクエストパス(第一引数)を指定し、`Request handler`を生成
7 | // リクエストに対応するレスポンスのモックを`Response resolver`により作成
8 | export const handlers = [
9 | ...auth.handlers,
10 | ...boards.handlers,
11 | ...lists.handlers,
12 | ...cards.handlers,
13 | ];
14 |
--------------------------------------------------------------------------------
/terraform/acm.tf:
--------------------------------------------------------------------------------
1 | # Certificates issue and validation using Route53 (record)
2 | # https://github.com/terraform-aws-modules/terraform-aws-acm
3 | module "acm" {
4 | source = "terraform-aws-modules/acm/aws"
5 |
6 | domain_name = "*.${data.aws_route53_zone.root.name}"
7 | zone_id = data.aws_route53_zone.root.zone_id
8 |
9 | wait_for_validation = true
10 |
11 | depends_on = [
12 | data.aws_route53_zone.root
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/backend/app/Http/Middleware/TrustHosts.php:
--------------------------------------------------------------------------------
1 | allSubdomainsOfApplicationUrl()];
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/backend/app/Http/Controllers/Controller.php:
--------------------------------------------------------------------------------
1 | make(Kernel::class)->bootstrap();
19 |
20 | return $app;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/frontend/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportHandler } from 'web-vitals';
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/frontend/src/templates/SubmitButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button, ButtonProps } from '@material-ui/core';
2 |
3 | import { useAppSelector } from 'utils/hooks';
4 |
5 | const SubmitButton = (props: ButtonProps) => {
6 | const loading = useAppSelector((state) => state.auth.loading);
7 |
8 | return (
9 |
16 | );
17 | };
18 |
19 | export default SubmitButton;
20 |
--------------------------------------------------------------------------------
/backend/app/Providers/BroadcastServiceProvider.php:
--------------------------------------------------------------------------------
1 | ;
8 | }
9 |
10 | export const isInvalidRequest = (payload: unknown): payload is InvalidRequest =>
11 | axios.isAxiosError(payload) &&
12 | payload.response?.status === 422 &&
13 | typeof payload.response?.data?.errors === 'object';
14 |
--------------------------------------------------------------------------------
/backend/database/seeders/TaskCardSeeder.php:
--------------------------------------------------------------------------------
1 | for(UserSeeder::$anotherUser)
19 | ->for(UserSeeder::$anotherUser->taskLists->first())
20 | ->create();
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/backend/database/seeders/TaskListSeeder.php:
--------------------------------------------------------------------------------
1 | for(UserSeeder::$anotherUser)
19 | ->for(UserSeeder::$anotherUser->taskBoards->first())
20 | ->create();
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/frontend/src/mocks/models/user.ts:
--------------------------------------------------------------------------------
1 | import { User } from 'models/User';
2 | import { CollectionBase, DocumentBase } from '.';
3 |
4 | export interface UserDocument extends DocumentBase {
5 | name: string;
6 | email: string;
7 | emailVerifiedAt: Date | null;
8 | password: string;
9 | }
10 |
11 | export interface UsersCollection extends CollectionBase {
12 | [uuid: string]: UserDocument;
13 | }
14 |
15 | export const sanitizeUser = (userDoc: UserDocument): User => {
16 | const { password, ...rest } = userDoc;
17 | return rest;
18 | };
19 |
--------------------------------------------------------------------------------
/frontend/src/layouts/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Header } from './Header';
2 | export { default as Footer } from './Footer';
3 | export { default as BaseLayout } from './BaseLayout';
4 | export { default as StandbyScreen } from './StandbyScreen';
5 | export { default as Loading } from './Loading';
6 | export { default as Progressbar } from './Progressbar';
7 | export { default as FlashNotification } from './FlashNotification';
8 | export { default as FormLayout } from './FormLayout';
9 | export { default as MarkdownWithToc } from './MarkdownWithToc';
10 |
--------------------------------------------------------------------------------
/frontend/src/utils/generator/generateRandomString.ts:
--------------------------------------------------------------------------------
1 | export const generateRandomString = (length?: number) => {
2 | const alphabet = {
3 | uppercase: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
4 | lowercase: 'abcdefghijklmnopqrstuvwxyz',
5 | };
6 | const decimal = '0123456789';
7 | const chars = alphabet.uppercase + alphabet.lowercase + decimal;
8 |
9 | const num = length || 12;
10 | let result = '';
11 | for (let i = 0; i < num; i++) {
12 | result += chars.charAt(Math.floor(Math.random() * chars.length));
13 | }
14 | return result;
15 | };
16 |
--------------------------------------------------------------------------------
/frontend/src/store/slices/appSlice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 |
3 | type AppState = {
4 | notFound: boolean;
5 | };
6 |
7 | const initialState = {} as AppState;
8 |
9 | export const appSlice = createSlice({
10 | name: 'app',
11 | initialState,
12 | reducers: {
13 | setError404(state) {
14 | state.notFound = true;
15 | },
16 | releaseError404(state) {
17 | state.notFound = initialState.notFound;
18 | },
19 | },
20 | });
21 |
22 | export const { setError404, releaseError404 } = appSlice.actions;
23 |
--------------------------------------------------------------------------------
/backend/app/Providers/AppServiceProvider.php:
--------------------------------------------------------------------------------
1 | call(UserSeeder::class);
18 | $this->call(TaskBoardSeeder::class);
19 | $this->call(TaskListSeeder::class);
20 | $this->call(TaskCardSeeder::class);
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/backend/lang/ja/pagination.php:
--------------------------------------------------------------------------------
1 | '次へ »',
16 | 'previous' => '« 前へ',
17 | ];
18 |
--------------------------------------------------------------------------------
/frontend/src/mocks/controllers/index.ts:
--------------------------------------------------------------------------------
1 | // auth
2 | export * as createUserController from './auth/createUser';
3 | export * as updateProfileController from './auth/updateProfile';
4 | export * as updatePasswordController from './auth/updatePassword';
5 | export * as resetPasswordController from './auth/resetPassword';
6 | export * as deleteAccountController from './auth/deleteAccount';
7 |
8 | export * as taskBoardController from './taskBoardController';
9 | export * as taskListController from './taskListController';
10 | export * as taskCardController from './taskCardController';
11 |
--------------------------------------------------------------------------------
/backend/routes/web.php:
--------------------------------------------------------------------------------
1 | '« Previous',
16 | 'next' => 'Next »',
17 | ];
18 |
--------------------------------------------------------------------------------
/frontend/src/utils/api/makePath.ts:
--------------------------------------------------------------------------------
1 | /** パスを構成する要素の内、モデルに対応するパス名 */
2 | const pathNames = ['users', 'task-boards', 'task-lists', 'task-cards'] as const;
3 |
4 | /** `pathName`と`id`(省略可)との組み合わせ */
5 | type PathSet = [pathName: typeof pathNames[number], id?: string];
6 |
7 | /** `PathSet`の配列(可変長)を連結してパスを作成 */
8 | export const makePath = (...props: PathSet[]) => {
9 | const reducer = (acc: string, current: typeof props[0]) => {
10 | const pathName = current[0];
11 | const id = current[1];
12 | return acc + `/${pathName}` + (id ? `/${id}` : '');
13 | };
14 | const path = props.reduce(reducer, '');
15 | return path;
16 | };
17 |
--------------------------------------------------------------------------------
/backend/webpack.mix.js:
--------------------------------------------------------------------------------
1 | const mix = require('laravel-mix');
2 |
3 | /*
4 | |--------------------------------------------------------------------------
5 | | Mix Asset Management
6 | |--------------------------------------------------------------------------
7 | |
8 | | Mix provides a clean, fluent API for defining some Webpack build steps
9 | | for your Laravel applications. By default, we are compiling the CSS
10 | | file for the application as well as bundling up all the JS files.
11 | |
12 | */
13 |
14 | mix
15 | .js('resources/js/app.js', 'public/js')
16 | .postCss('resources/css/app.css', 'public/css', [
17 | //
18 | ]);
19 |
--------------------------------------------------------------------------------
/backend/app/Http/Controllers/HomeController.php:
--------------------------------------------------------------------------------
1 | json([
20 | 'message' => 'Welcome to ' . config('app.name'),
21 | ]);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/backend/routes/channels.php:
--------------------------------------------------------------------------------
1 | id === (int) $id;
18 | });
19 |
--------------------------------------------------------------------------------
/backend/server.php:
--------------------------------------------------------------------------------
1 |
8 | */
9 |
10 | $uri = urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
11 |
12 | // This file allows us to emulate Apache's "mod_rewrite" functionality from the
13 | // built-in PHP web server. This provides a convenient way to test a Laravel
14 | // application without having installed a "real" web server software here.
15 | if ($uri !== '/' && file_exists(__DIR__ . '/public' . $uri)) {
16 | return false;
17 | }
18 |
19 | require_once __DIR__ . '/public/index.php';
20 |
--------------------------------------------------------------------------------
/frontend/src/mocks/store.ts:
--------------------------------------------------------------------------------
1 | import type { CurriedGetDefaultMiddleware } from '@reduxjs/toolkit/dist/getDefaultMiddleware';
2 | import { configureStore } from '@reduxjs/toolkit';
3 |
4 | import { rootReducer } from 'store';
5 |
6 | const options = {
7 | reducer: rootReducer,
8 | middleware: (getDefaultMiddleware: CurriedGetDefaultMiddleware) =>
9 | getDefaultMiddleware({ serializableCheck: false }),
10 | };
11 |
12 | export const store = configureStore(options);
13 |
14 | export const initializeStore = () => {
15 | const newStore = configureStore(options);
16 | Object.assign(store, { ...newStore });
17 | };
18 |
19 | export default store;
20 |
--------------------------------------------------------------------------------
/backend/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // https://code.visualstudio.com/docs/remote/devcontainerjson-reference
2 | {
3 | "dockerComposeFile": [
4 | "../docker-compose.yml",
5 | "../docker-compose.extend.yml"
6 | ],
7 | "service": "laravel.test",
8 | "workspaceFolder": "/var/www/html/backend",
9 | "remoteUser": "sail",
10 | "extensions": [
11 | "bmewburn.vscode-intelephense-client", // PHP intellisense
12 | "amiralizadeh9480.laravel-extra-intellisense", // Laravel intellisense
13 | "dbaeumer.vscode-eslint", // ESLint
14 | "esbenp.prettier-vscode", // Prettier
15 | "streetsidesoftware.code-spell-checker"
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/backend/lang/ja/auth.php:
--------------------------------------------------------------------------------
1 | '認証に失敗しました。',
16 | 'password' => 'パスワードが間違っています。',
17 | 'throttle' =>
18 | 'ログインの試行回数が多すぎます。:seconds 秒後にお試しください。',
19 | ];
20 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/auth/index.ts:
--------------------------------------------------------------------------------
1 | // Enable imports from the higher level. Here is an example
2 | // before: import { createUser } from 'store/thunks/createUser';
3 | // after: import { createUser } from 'store/thunks';
4 | export * from './createUser';
5 | export * from './fetchAuthUser';
6 | export * from './sendEmailVerificationLink';
7 | export { default as verifyEmail } from './verifyEmail';
8 | export * from './signInWithEmail';
9 | export * from './updateProfile';
10 | export * from './updatePassword';
11 | export * from './forgotPassword';
12 | export * from './resetPassword';
13 | export * from './signOutFromAPI';
14 | export * from './deleteAccount';
15 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "esModuleInterop": true,
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "noFallthroughCasesInSwitch": true,
12 | "downlevelIteration": true,
13 | "module": "esnext",
14 | "moduleResolution": "node",
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "noEmit": true,
18 | "jsx": "react-jsx",
19 | "baseUrl": "src"
20 | },
21 | "include": ["src"]
22 | }
23 |
--------------------------------------------------------------------------------
/backend/app/Http/Controllers/Auth/RegisteredUserController.php:
--------------------------------------------------------------------------------
1 | delete();
21 |
22 | return response()->json([], 204);
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/frontend/.babelrc.js:
--------------------------------------------------------------------------------
1 | const plugins = [
2 | [
3 | 'babel-plugin-import',
4 | {
5 | libraryName: '@material-ui/core',
6 | // Use "'libraryDirectory': ''," if your bundler does not support ES modules
7 | libraryDirectory: 'esm',
8 | camel2DashComponentName: false,
9 | },
10 | 'core',
11 | ],
12 | [
13 | 'babel-plugin-import',
14 | {
15 | libraryName: '@material-ui/icons',
16 | // Use "'libraryDirectory': ''," if your bundler does not support ES modules
17 | libraryDirectory: 'esm',
18 | camel2DashComponentName: false,
19 | },
20 | 'icons',
21 | ],
22 | ];
23 |
24 | module.exports = { plugins };
25 |
--------------------------------------------------------------------------------
/terraform/s3.tf:
--------------------------------------------------------------------------------
1 | # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket
2 | resource "aws_s3_bucket" "main" {
3 | bucket = "${lower(var.project)}-${var.stage}"
4 | }
5 |
6 | # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_acl
7 | resource "aws_s3_bucket_acl" "main" {
8 | bucket = aws_s3_bucket.main.id
9 | acl = "private"
10 | }
11 |
12 | # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket_versioning
13 | resource "aws_s3_bucket_versioning" "main" {
14 | bucket = aws_s3_bucket.main.id
15 | versioning_configuration {
16 | status = "Enabled"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/backend/app/Http/Controllers/Auth/ProfileInformationController.php:
--------------------------------------------------------------------------------
1 | comment(Inspiring::quote());
19 | })->purpose('Display an inspiring quote');
20 |
--------------------------------------------------------------------------------
/frontend/src/mocks/utils/store/auth.ts:
--------------------------------------------------------------------------------
1 | import { EnhancedStore } from '@reduxjs/toolkit';
2 |
3 | import { RootState } from 'store';
4 |
5 | export const getUserState = (store: EnhancedStore) =>
6 | store.getState().auth.user;
7 |
8 | export const isAfterRegistration = (store: EnhancedStore) =>
9 | store.getState().auth.afterRegistration;
10 |
11 | export const isSignedIn = (store: EnhancedStore) =>
12 | store.getState().auth.signedIn;
13 |
14 | export const isLoading = (store: EnhancedStore) =>
15 | store.getState().auth.loading;
16 |
17 | export const getFlashState = (store: EnhancedStore) =>
18 | store.getState().auth.flash;
19 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/auth/signOutFromAPI.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { SIGNOUT_PATH } from 'config/api';
4 | import { apiClient } from 'utils/api';
5 | import { AsyncThunkConfig } from 'store/thunks/config';
6 | import { makeRejectValue } from 'store/thunks/utils';
7 |
8 | export const signOutFromAPI = createAsyncThunk(
9 | 'auth/signOutFromAPI',
10 | async (_, thunkApi) => {
11 | try {
12 | // status(response): ログイン状態によらず`204` 認証切れなら`419`
13 | await apiClient().post(SIGNOUT_PATH);
14 | } catch (error) {
15 | return thunkApi.rejectWithValue(makeRejectValue(error));
16 | }
17 | }
18 | );
19 |
--------------------------------------------------------------------------------
/frontend/src/mocks/controllers/auth/createUser.ts:
--------------------------------------------------------------------------------
1 | import { SignUpRequest, SignUpResponse } from 'store/thunks/auth';
2 | import { db, auth } from 'mocks/models';
3 | import { sanitizeUser, UserDocument } from 'mocks/models/user';
4 | import { digestText } from 'mocks/utils/crypto';
5 |
6 | export const store = (request: SignUpRequest): SignUpResponse => {
7 | const newUserDoc = {
8 | name: request.email,
9 | email: request.email,
10 | emailVerifiedAt: null,
11 | password: digestText(request.password),
12 | } as UserDocument;
13 |
14 | const createdUser = db.create('users', newUserDoc);
15 | auth.login(createdUser);
16 |
17 | return { user: sanitizeUser(createdUser) };
18 | };
19 |
--------------------------------------------------------------------------------
/backend/public/.htaccess:
--------------------------------------------------------------------------------
1 |
2 |
3 | Options -MultiViews -Indexes
4 |
5 |
6 | RewriteEngine On
7 |
8 | # Handle Authorization Header
9 | RewriteCond %{HTTP:Authorization} .
10 | RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
11 |
12 | # Redirect Trailing Slashes If Not A Folder...
13 | RewriteCond %{REQUEST_FILENAME} !-d
14 | RewriteCond %{REQUEST_URI} (.+)/$
15 | RewriteRule ^ %1 [L,R=301]
16 |
17 | # Send Requests To Front Controller...
18 | RewriteCond %{REQUEST_FILENAME} !-d
19 | RewriteCond %{REQUEST_FILENAME} !-f
20 | RewriteRule ^ index.php [L]
21 |
22 |
--------------------------------------------------------------------------------
/frontend/src/layouts/BaseLayout.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment } from 'react';
2 |
3 | import { Helmet } from 'react-helmet-async';
4 |
5 | import { APP_NAME } from 'config/app';
6 | import { Header, Footer } from 'layouts';
7 |
8 | type BaseLayoutProps = { subtitle: string; withoutHeaders?: boolean };
9 |
10 | const BaseLayout: React.FC = (props) => (
11 |
12 |
13 |
14 | {props.subtitle ? `${props.subtitle} | ${APP_NAME}` : APP_NAME}
15 |
16 |
17 | {!props.withoutHeaders && }
18 | {props.children}
19 | {!props.withoutHeaders && }
20 |
21 | );
22 |
23 | export default BaseLayout;
24 |
--------------------------------------------------------------------------------
/frontend/src/mocks/controllers/auth/resetPassword.ts:
--------------------------------------------------------------------------------
1 | import { ResetPasswordRequest } from 'store/thunks/auth';
2 | import { auth, db, UserDocument } from 'mocks/models';
3 | import { digestText } from 'mocks/utils/crypto';
4 |
5 | export const reset = (request: ResetPasswordRequest) => {
6 | if (request.password !== request.password_confirmation)
7 | throw new Error('Passwords do not match');
8 |
9 | const requestedUser = db.where('users', 'email', request.email)[0];
10 | const newUserDoc: UserDocument = {
11 | ...requestedUser,
12 | updatedAt: new Date(),
13 | password: digestText(request.password),
14 | };
15 |
16 | db.update('users', newUserDoc);
17 | auth.login(newUserDoc);
18 | };
19 |
--------------------------------------------------------------------------------
/backend/lang/ja/passwords.php:
--------------------------------------------------------------------------------
1 | 'パスワードをリセットしました。',
16 | 'sent' => 'パスワードリマインダーを送信しました。',
17 | 'throttled' => '時間を置いて再度お試しください。',
18 | 'token' => 'このパスワード再設定トークンは無効です。',
19 | 'user' => 'このメールアドレスに一致するユーザーがいません。',
20 | ];
21 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/utils/makeRejectValue.ts:
--------------------------------------------------------------------------------
1 | import {
2 | isHttpException,
3 | isInvalidRequest,
4 | makeErrorMessageFrom,
5 | } from 'utils/api/errors';
6 | import { RejectValue } from 'store/thunks/config';
7 |
8 | export const makeRejectValue = (error: unknown): RejectValue => {
9 | if (isInvalidRequest(error))
10 | return {
11 | error: {
12 | ...error,
13 | message: makeErrorMessageFrom(error),
14 | },
15 | };
16 | if (isHttpException(error))
17 | return {
18 | error: {
19 | ...error,
20 | message: `${error.response.status}: ${error.response.data.message}`,
21 | },
22 | };
23 | return {
24 | error: { message: String(error) },
25 | };
26 | };
27 |
--------------------------------------------------------------------------------
/frontend/src/templates/ScrolledDiv.tsx:
--------------------------------------------------------------------------------
1 | import { makeStyles } from '@material-ui/core/styles';
2 |
3 | const useStyles = makeStyles({
4 | root: {
5 | overflow: 'auto',
6 | '&::-webkit-scrollbar': { width: '8px', height: '8px' },
7 | '&::-webkit-scrollbar-track': { backgroundColor: '#eee' },
8 | '&::-webkit-scrollbar-thumb': { backgroundColor: '#ccc' },
9 | },
10 | });
11 |
12 | const ScrolledDiv = (props: JSX.IntrinsicElements['div']) => {
13 | const { className, ...other } = props;
14 | const classes = useStyles();
15 |
16 | return (
17 |
21 | );
22 | };
23 |
24 | export default ScrolledDiv;
25 |
--------------------------------------------------------------------------------
/backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "scripts": {
4 | "dev": "npm run development",
5 | "development": "mix",
6 | "watch": "mix watch",
7 | "watch-poll": "mix watch -- --watch-options-poll=1000",
8 | "hot": "mix watch --hot",
9 | "prod": "npm run production",
10 | "production": "mix --production",
11 | "prettier-fix": "prettier --write ."
12 | },
13 | "devDependencies": {
14 | "@prettier/plugin-php": "^0.18.4",
15 | "axios": "^0.21",
16 | "laravel-mix": "^6.0.6",
17 | "lodash": "^4.17.19",
18 | "postcss": "^8.1.14",
19 | "prettier": "2.6.2",
20 | "serverless-domain-manager": "^5.1.5"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/frontend/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 | import { server } from './mocks/server';
7 |
8 | import './mocks/data';
9 |
10 | beforeAll(() => {
11 | server.listen({ onUnhandledRequest: 'warn' }); // Enable the mocking in tests.
12 | window.scrollTo = jest.fn();
13 | });
14 |
15 | afterEach(() => {
16 | server.resetHandlers(); // Reset any runtime handlers tests may use.
17 | });
18 |
19 | afterAll(() => {
20 | server.close(); // Clean up once the tests are done.
21 | jest.clearAllMocks();
22 | });
23 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/auth/deleteAccount.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { SIGNUP_PATH } from 'config/api';
4 | import { apiClient } from 'utils/api';
5 | import { AsyncThunkConfig } from 'store/thunks/config';
6 | import { makeRejectValue } from 'store/thunks/utils';
7 |
8 | type DeleteAccountResponse = void;
9 | type DeleteAccountRequest = void;
10 |
11 | export const deleteAccount = createAsyncThunk<
12 | DeleteAccountResponse,
13 | DeleteAccountRequest,
14 | AsyncThunkConfig
15 | >('auth/deleteAccount', async (_, thunkApi) => {
16 | try {
17 | await apiClient().delete(SIGNUP_PATH);
18 | } catch (error) {
19 | return thunkApi.rejectWithValue(makeRejectValue(error));
20 | }
21 | });
22 |
--------------------------------------------------------------------------------
/backend/routes/api.php:
--------------------------------------------------------------------------------
1 | name('v1.')
21 | ->group(function () {
22 | require __DIR__ . '/api/v1.php';
23 | });
24 |
--------------------------------------------------------------------------------
/frontend/src/templates/ScrolledTypography.tsx:
--------------------------------------------------------------------------------
1 | import { makeStyles } from '@material-ui/core/styles';
2 | import { Typography, TypographyProps } from '@material-ui/core';
3 |
4 | const useStyles = makeStyles({
5 | root: {
6 | whiteSpace: 'nowrap',
7 | overflowX: 'hidden',
8 | '&:hover': { overflowX: 'auto' },
9 | '&::-webkit-scrollbar': { height: '0px' },
10 | },
11 | });
12 |
13 | const ScrolledTypography = (props: TypographyProps) => {
14 | const { classes, ...typographyProps } = props;
15 | const styles = useStyles();
16 | const root = classes?.root ? `${styles.root} ${classes.root}` : styles.root;
17 |
18 | return ;
19 | };
20 |
21 | export default ScrolledTypography;
22 |
--------------------------------------------------------------------------------
/backend/lang/en/auth.php:
--------------------------------------------------------------------------------
1 | 'These credentials do not match our records.',
16 | 'password' => 'The provided password is incorrect.',
17 | 'throttle' =>
18 | 'Too many login attempts. Please try again in :seconds seconds.',
19 | ];
20 |
--------------------------------------------------------------------------------
/frontend/src/mocks/controllers/auth/updatePassword.ts:
--------------------------------------------------------------------------------
1 | import { UpdatePasswordRequest } from 'store/thunks/auth';
2 | import { db, UserDocument } from 'mocks/models';
3 | import { digestText } from 'mocks/utils/crypto';
4 |
5 | type UpdatePasswordProps = {
6 | currentUser: UserDocument;
7 | request: UpdatePasswordRequest;
8 | };
9 |
10 | export const update = (props: UpdatePasswordProps) => {
11 | const { currentUser, request } = props;
12 |
13 | if (request.password !== request.password_confirmation)
14 | throw new Error('Passwords do not match');
15 |
16 | const newUserDoc: UserDocument = {
17 | ...currentUser,
18 | updatedAt: new Date(),
19 | password: digestText(request.password),
20 | };
21 |
22 | db.update('users', newUserDoc);
23 | };
24 |
--------------------------------------------------------------------------------
/backend/app/Http/Middleware/TrustProxies.php:
--------------------------------------------------------------------------------
1 | new UserResource(Auth::user())]);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/frontend/src/templates/AlertMessage.tsx:
--------------------------------------------------------------------------------
1 | import { Alert, AlertProps, AlertTitle, Color } from '@material-ui/lab';
2 |
3 | const headerMap: Record> = {
4 | success: 'Success',
5 | info: 'Info',
6 | warning: 'Warning',
7 | error: 'Error',
8 | };
9 |
10 | type AlertMessageProps = {
11 | severity: Color;
12 | header?: string;
13 | body?: string;
14 | } & AlertProps;
15 |
16 | const AlertMessage = (props: AlertMessageProps) => {
17 | const { header, body, ...alertProps } = props;
18 | const title = props.header || headerMap[props.severity];
19 |
20 | return (
21 |
22 | {title}
23 | {props.body || props.children}
24 |
25 | );
26 | };
27 |
28 | export default AlertMessage;
29 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/auth/updatePassword.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { UPDATE_PASSWORD_PATH } from 'config/api';
4 | import { apiClient } from 'utils/api';
5 | import { AsyncThunkConfig } from 'store/thunks/config';
6 | import { makeRejectValue } from 'store/thunks/utils';
7 |
8 | export type UpdatePasswordRequest = {
9 | current_password: string;
10 | password: string;
11 | password_confirmation: string;
12 | };
13 |
14 | export const updatePassword = createAsyncThunk<
15 | void,
16 | UpdatePasswordRequest,
17 | AsyncThunkConfig
18 | >('auth/updatePassword', async (payload, thunkApi) => {
19 | try {
20 | await apiClient().put(UPDATE_PASSWORD_PATH, payload);
21 | } catch (error) {
22 | return thunkApi.rejectWithValue(makeRejectValue(error));
23 | }
24 | });
25 |
--------------------------------------------------------------------------------
/backend/app/Providers/EventServiceProvider.php:
--------------------------------------------------------------------------------
1 | [SendEmailVerificationNotification::class],
19 | ];
20 |
21 | /**
22 | * Register any events for your application.
23 | *
24 | * @return void
25 | */
26 | public function boot()
27 | {
28 | //
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/frontend/src/models/Task.ts:
--------------------------------------------------------------------------------
1 | import type { CollectionBase, DocumentBase } from 'models';
2 | import type { IndexMap } from 'utils/dnd';
3 |
4 | export type TaskBoard = {
5 | userId: string;
6 | title: string;
7 | description: string;
8 | lists: TaskList[];
9 | listIndexMap: IndexMap;
10 | cardIndexMap: IndexMap;
11 | } & DocumentBase;
12 |
13 | export type TaskBoardsCollection = CollectionBase;
14 |
15 | export type TaskList = {
16 | boardId: string;
17 | title: string;
18 | description: string;
19 | cards: TaskCard[];
20 | } & DocumentBase;
21 |
22 | export type TaskListsCollection = CollectionBase;
23 |
24 | export type TaskCard = {
25 | listId: string;
26 | boardId: string;
27 | title: string;
28 | content: string;
29 | deadline: Date;
30 | done: boolean;
31 | } & DocumentBase;
32 |
--------------------------------------------------------------------------------
/frontend/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 |
3 | import { BrowserRouter as Router } from 'react-router-dom';
4 |
5 | import { initializeAuthState, isReady } from './utils/auth';
6 | import { useAppSelector } from './utils/hooks';
7 | import { Loading, FlashNotification } from './layouts';
8 | import Routes from './Routes';
9 |
10 | const App: React.FC = () => {
11 | const signedIn = useAppSelector((state) => state.auth.signedIn);
12 |
13 | // `localStorage`と`store`のログイン状態を初期化
14 | useEffect(() => {
15 | initializeAuthState();
16 | }, [signedIn]);
17 |
18 | // 値を評価する準備が整うまで待機
19 | if (!isReady()) return ;
20 |
21 | return (
22 |
23 |
24 |
25 |
26 |
27 | );
28 | };
29 |
30 | export default App;
31 |
--------------------------------------------------------------------------------
/backend/lang/en/passwords.php:
--------------------------------------------------------------------------------
1 | 'Your password has been reset!',
16 | 'sent' => 'We have emailed your password reset link!',
17 | 'throttled' => 'Please wait before retrying.',
18 | 'token' => 'This password reset token is invalid.',
19 | 'user' => "We can't find a user with that email address.",
20 | ];
21 |
--------------------------------------------------------------------------------
/frontend/.env.example:
--------------------------------------------------------------------------------
1 | # If true, tests will only run once instead of launching the watcher.
2 | # CI=true
3 |
4 | REACT_APP_APP_NAME='Task Management App'
5 | REACT_APP_API_HOST=https://localhost
6 | REACT_APP_API_VERSION=v1
7 |
8 | # paths
9 | REACT_APP_GET_CSRF_TOKEN_PATH = '/sanctum/csrf-cookie'
10 | REACT_APP_SIGNUP_PATH = '/register'
11 | REACT_APP_VERIFICATION_NOTIFICATION_PATH = '/email/verification-notification'
12 | REACT_APP_SIGNIN_PATH = '/login'
13 | REACT_APP_USER_INFO_PATH = '/user/profile-information'
14 | REACT_APP_UPDATE_PASSWORD_PATH = '/user/password'
15 | REACT_APP_SIGNOUT_PATH = '/logout'
16 | REACT_APP_FORGOT_PASSWORD_PATH = '/forgot-password'
17 | REACT_APP_RESET_PASSWORD_PATH = '/reset-password'
18 |
19 | REACT_APP_GUEST_NAME='ゲストユーザー'
20 | REACT_APP_GUEST_EMAIL=test@example.com
21 | REACT_APP_GUEST_PASSWORD=password
22 |
--------------------------------------------------------------------------------
/frontend/src/mocks/models/auth.ts:
--------------------------------------------------------------------------------
1 | import { UserDocument } from 'mocks/models';
2 |
3 | /**
4 | * セッション切断まで保持される認証ユーザー
5 | */
6 | const user = {} as UserDocument;
7 |
8 | const exists = () => Object.keys(user).length > 0;
9 |
10 | /**
11 | * 認証ユーザー取得
12 | */
13 | export const getUser = () => {
14 | const userRef = { ...user };
15 | return exists() ? userRef : null;
16 | };
17 |
18 | /**
19 | * 認証ユーザーとして登録
20 | */
21 | export const setUser = (userDoc: UserDocument) => Object.assign(user, userDoc);
22 |
23 | /**
24 | * 認証ユーザーとして登録
25 | */
26 | export const login = (userDoc: UserDocument) => setUser(userDoc);
27 |
28 | /**
29 | * 登録した認証ユーザーを削除し、セッション(sessionStorage)を破棄
30 | */
31 | export const logout = () => {
32 | Object.keys(user).forEach((key) => delete user[key as keyof typeof user]);
33 | sessionStorage.clear();
34 | };
35 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/auth/sendEmailVerificationLink.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 | import { AxiosResponse } from 'axios';
3 |
4 | import { VERIFICATION_NOTIFICATION_PATH } from 'config/api';
5 | import { apiClient } from 'utils/api';
6 | import { AsyncThunkConfig } from 'store/thunks/config';
7 | import { makeRejectValue } from 'store/thunks/utils';
8 |
9 | export const sendEmailVerificationLink = createAsyncThunk<
10 | AxiosResponse['status'],
11 | void,
12 | AsyncThunkConfig
13 | >('auth/sendVerificationLink', async (_, thunkApi) => {
14 | try {
15 | // 正常時は`202`(認証済みの場合`204`)
16 | const response = await apiClient().post(VERIFICATION_NOTIFICATION_PATH);
17 | return response.status;
18 | } catch (error) {
19 | return thunkApi.rejectWithValue(makeRejectValue(error));
20 | }
21 | });
22 |
--------------------------------------------------------------------------------
/frontend/src/layouts/Loading.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment } from 'react';
2 |
3 | import { makeStyles, createStyles, Theme } from '@material-ui/core/styles';
4 | import { Backdrop, CircularProgress } from '@material-ui/core';
5 |
6 | import { useAppSelector } from 'utils/hooks';
7 |
8 | const useStyles = makeStyles((theme: Theme) =>
9 | createStyles({
10 | backdrop: {
11 | zIndex: theme.zIndex.drawer + 1,
12 | color: '#fff',
13 | },
14 | })
15 | );
16 |
17 | const Loading = () => {
18 | const classes = useStyles();
19 | const loading = useAppSelector((state) => state.auth.loading);
20 |
21 | if (!loading) return ;
22 |
23 | return (
24 |
25 |
26 |
27 | );
28 | };
29 |
30 | export default Loading;
31 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/auth/fetchAuthUser.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { USER_INFO_PATH } from 'config/api';
4 | import { User } from 'models/User';
5 | import { apiClient } from 'utils/api';
6 | import { AsyncThunkConfig } from 'store/thunks/config';
7 | import { makeRejectValue } from 'store/thunks/utils';
8 |
9 | export type FetchAuthUserResponse = {
10 | user: User;
11 | };
12 |
13 | export const fetchAuthUser = createAsyncThunk<
14 | FetchAuthUserResponse,
15 | void,
16 | AsyncThunkConfig
17 | >('auth/fetchAuthUser', async (_, thunkApi) => {
18 | try {
19 | const response = await apiClient({ intercepted: false }).get(
20 | USER_INFO_PATH
21 | );
22 | return response?.data;
23 | } catch (error) {
24 | return thunkApi.rejectWithValue(makeRejectValue(error));
25 | }
26 | });
27 |
--------------------------------------------------------------------------------
/frontend/src/utils/hooks/useDeepEqualSelector.ts:
--------------------------------------------------------------------------------
1 | import { deepStrictEqual } from 'assert';
2 |
3 | import { RootState } from 'store';
4 | import { useAppSelector } from '.';
5 |
6 | /**
7 | * - 二つのオブジェクトの各プロパティ値の等価性を検証
8 | * - node`assert`の`deepStrictEqual`をロジックに使用
9 | * - `useSelector`の比較メソッド用に`boolean`を返却する仕様
10 | *
11 | * @returns 等価の場合`true`
12 | */
13 | const deepEqual = (left: T, right: T) => {
14 | try {
15 | deepStrictEqual(left, right);
16 | return true;
17 | } catch (e) {
18 | return false;
19 | }
20 | };
21 |
22 | /**
23 | * - `useAppSelector`の拡張
24 | * - 比較ロジックをデフォルトの`===`から`deepStrictEqual`に変更
25 | *
26 | * @see https://react-redux.js.org/api/hooks#equality-comparisons-and-updates
27 | */
28 | export const useDeepEqualSelector = (selector: (state: RootState) => T) =>
29 | useAppSelector(selector, deepEqual);
30 |
--------------------------------------------------------------------------------
/frontend/src/theme/typography.ts:
--------------------------------------------------------------------------------
1 | import { TypographyOptions } from '@material-ui/core/styles/createTypography';
2 |
3 | const typography: TypographyOptions = {
4 | h1: {
5 | fontWeight: 'bold',
6 | fontSize: '2.8rem',
7 | letterSpacing: '-0.24px',
8 | },
9 | h2: {
10 | fontWeight: 'bold',
11 | fontSize: '2.4rem',
12 | letterSpacing: '-0.24px',
13 | },
14 | h3: {
15 | fontWeight: 'bold',
16 | fontSize: '2rem',
17 | letterSpacing: '-0.06px',
18 | },
19 | h4: {
20 | fontWeight: 'bold',
21 | fontSize: '1.6rem',
22 | letterSpacing: '-0.06px',
23 | },
24 | h5: {
25 | fontWeight: 500,
26 | fontSize: '1.2rem',
27 | letterSpacing: '-0.05px',
28 | },
29 | h6: {
30 | fontWeight: 500,
31 | fontSize: '1rem',
32 | letterSpacing: '-0.05px',
33 | },
34 | };
35 |
36 | export default typography;
37 |
--------------------------------------------------------------------------------
/frontend/src/layouts/Progressbar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { makeStyles, Theme } from '@material-ui/core/styles';
4 | import { LinearProgress, LinearProgressProps } from '@material-ui/core';
5 |
6 | import { useAppSelector } from 'utils/hooks';
7 |
8 | const useStyles = makeStyles((theme: Theme) => ({
9 | root: { marginTop: theme.spacing(1) },
10 | }));
11 |
12 | const Progressbar = (props: LinearProgressProps) => {
13 | const { root } = useStyles();
14 | const loading = useAppSelector(
15 | (state) => state.auth.loading || state.boards.loading
16 | );
17 |
18 | if (loading)
19 | return (
20 |
26 | );
27 | else return ;
28 | };
29 |
30 | export default Progressbar;
31 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/auth/verifyEmail.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import type { AsyncThunkConfig } from 'store/thunks/config';
4 | import type { User } from 'models/User';
5 | import { apiClient } from 'utils/api';
6 | import { makeRejectValue } from 'store/thunks/utils';
7 |
8 | export type VerifyEmailRequest = { url: string };
9 | export type VerifyEmailResponse = { user: User };
10 |
11 | const verifyEmail = createAsyncThunk<
12 | VerifyEmailResponse,
13 | VerifyEmailRequest,
14 | AsyncThunkConfig
15 | >('auth/verifyEmail', async (payload, thunkApi) => {
16 | try {
17 | const response = await apiClient().get(payload.url);
18 |
19 | return response.data;
20 | } catch (error) {
21 | return thunkApi.rejectWithValue(makeRejectValue(error));
22 | }
23 | });
24 |
25 | export default verifyEmail;
26 |
--------------------------------------------------------------------------------
/backend/app/Http/Middleware/RedirectIfAuthenticated.php:
--------------------------------------------------------------------------------
1 | check()) {
25 | return redirect()->intended(config('fortify.home'));
26 | }
27 | }
28 |
29 | return $next($request);
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/frontend/src/pages/auth/VerifyEmail.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { useHistory } from 'react-router-dom';
3 |
4 | import { useAppDispatch } from 'utils/hooks';
5 | import { verifyEmail } from 'store/thunks/auth';
6 | import { BaseLayout } from 'layouts';
7 |
8 | const VerifyEmail = () => {
9 | const history = useHistory();
10 | const dispatch = useAppDispatch();
11 |
12 | useEffect(() => {
13 | (async () => {
14 | const url = window.location.pathname + window.location.search;
15 | const response = await dispatch(verifyEmail({ url }));
16 |
17 | if (verifyEmail.rejected.match(response))
18 | return history.replace('/account');
19 |
20 | history.replace('/');
21 | })();
22 | }, [dispatch, history]);
23 |
24 | return ;
25 | };
26 |
27 | export default VerifyEmail;
28 |
--------------------------------------------------------------------------------
/backend/database/migrations/2014_10_12_100000_create_password_resets_table.php:
--------------------------------------------------------------------------------
1 | string('email')->index();
18 | $table->string('token');
19 | $table->timestamp('created_at')->nullable();
20 | });
21 | }
22 |
23 | /**
24 | * Reverse the migrations.
25 | *
26 | * @return void
27 | */
28 | public function down()
29 | {
30 | Schema::dropIfExists('password_resets');
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/frontend/src/templates/LinkWrapper.tsx:
--------------------------------------------------------------------------------
1 | import { Link as RouterLink } from 'react-router-dom';
2 | import { createStyles, makeStyles } from '@material-ui/core/styles';
3 | import { Link, LinkProps } from '@material-ui/core';
4 |
5 | const useStyles = makeStyles(() =>
6 | createStyles({
7 | root: {
8 | color: 'inherit',
9 | '&:hover': {
10 | color: 'inherit',
11 | textDecoration: 'none',
12 | },
13 | },
14 | })
15 | );
16 |
17 | type LinkWrapperProps = {
18 | to: string;
19 | } & LinkProps;
20 |
21 | const LinkWrapper = (props: LinkWrapperProps) => {
22 | const { to, ...linkProps } = props;
23 | const { root } = useStyles();
24 |
25 | return (
26 |
32 | );
33 | };
34 |
35 | export default LinkWrapper;
36 |
--------------------------------------------------------------------------------
/backend/app/Actions/Fortify/ResetUserPassword.php:
--------------------------------------------------------------------------------
1 | $this->passwordRules(),
24 | ])->validate();
25 |
26 | $user
27 | ->forceFill([
28 | 'password' => Hash::make($input['password']),
29 | ])
30 | ->save();
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/frontend/src/templates/ScrolledGridContainer.tsx:
--------------------------------------------------------------------------------
1 | import { makeStyles } from '@material-ui/core/styles';
2 | import { Grid, GridProps } from '@material-ui/core';
3 |
4 | const useStyles = makeStyles({
5 | root: {
6 | overflow: 'auto',
7 | '&::-webkit-scrollbar': { width: '8px', height: '8px' },
8 | '&::-webkit-scrollbar-track': { backgroundColor: '#eee' },
9 | '&::-webkit-scrollbar-thumb': { backgroundColor: '#ccc' },
10 | },
11 | });
12 |
13 | const ScrolledGridContainer = (props: GridProps) => {
14 | const { classes, ...gridProps } = props;
15 | const styles = useStyles();
16 | const root = classes?.root ? `${styles.root} ${classes.root}` : styles.root;
17 |
18 | return (
19 |
25 | );
26 | };
27 |
28 | export default ScrolledGridContainer;
29 |
--------------------------------------------------------------------------------
/backend/docker/8.0/supervisord.conf:
--------------------------------------------------------------------------------
1 | [supervisord]
2 | nodaemon=true
3 | user=root
4 | logfile=/var/log/supervisor/supervisord.log
5 | pidfile=/var/run/supervisord.pid
6 |
7 | [program:php]
8 | command=/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80
9 | user=sail
10 | environment=LARAVEL_SAIL="1"
11 | stdout_logfile=/dev/stdout
12 | stdout_logfile_maxbytes=0
13 | stderr_logfile=/dev/stderr
14 | stderr_logfile_maxbytes=0
15 |
16 | [program:laravel-worker]
17 | process_name=%(program_name)s_%(process_num)02d
18 | numprocs=8
19 | command=php /var/www/html/artisan queue:work --sleep=3 --tries=3 --max-time=3600
20 | autostart=true
21 | autorestart=true
22 | stopasgroup=true
23 | killasgroup=true
24 | user=sail
25 | redirect_stderr=true
26 | stdout_logfile=/var/www/html/storage/logs/worker.log
27 | stderr_logfile=/var/www/html/storage/logs/worker_error.log
28 | stopwaitsecs=3600
29 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/auth/forgotPassword.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { GET_CSRF_TOKEN_PATH, FORGOT_PASSWORD_PATH } from 'config/api';
4 | import { apiClient } from 'utils/api';
5 | import { AsyncThunkConfig } from 'store/thunks/config';
6 | import { makeRejectValue } from 'store/thunks/utils';
7 |
8 | export type ForgotPasswordResponse = void;
9 |
10 | export type ForgotPasswordRequest = {
11 | email: string;
12 | };
13 |
14 | export const forgotPassword = createAsyncThunk<
15 | ForgotPasswordResponse,
16 | ForgotPasswordRequest,
17 | AsyncThunkConfig
18 | >('auth/forgotPassword', async (payload, thunkApi) => {
19 | try {
20 | await apiClient({ apiRoute: false }).get(GET_CSRF_TOKEN_PATH);
21 | await apiClient().post(FORGOT_PASSWORD_PATH, payload);
22 | } catch (error) {
23 | return thunkApi.rejectWithValue(makeRejectValue(error));
24 | }
25 | });
26 |
--------------------------------------------------------------------------------
/backend/app/Http/Responses/LoginResponse.php:
--------------------------------------------------------------------------------
1 | wantsJson()
21 | ? response()->json([
22 | 'user' => new UserResource(Auth::user()),
23 | 'two_factor' => false,
24 | ])
25 | : redirect()->intended(config('fortify.home'));
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/auth/updateProfile.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { USER_INFO_PATH } from 'config/api';
4 | import { apiClient } from 'utils/api';
5 | import { AsyncThunkConfig } from 'store/thunks/config';
6 | import { makeRejectValue } from 'store/thunks/utils';
7 |
8 | export type UpdateProfileResponse = {
9 | name: string;
10 | email: string;
11 | };
12 |
13 | export type UpdateProfileRequest = {
14 | name: string;
15 | email: string;
16 | };
17 |
18 | export const updateProfile = createAsyncThunk<
19 | UpdateProfileResponse,
20 | UpdateProfileRequest,
21 | AsyncThunkConfig
22 | >('auth/updateProfile', async (payload, thunkApi) => {
23 | try {
24 | await apiClient().put(USER_INFO_PATH, payload);
25 | return payload; // fulfill時は、requestの値をそのまま`return`
26 | } catch (error) {
27 | return thunkApi.rejectWithValue(makeRejectValue(error));
28 | }
29 | });
30 |
--------------------------------------------------------------------------------
/backend/app/Http/Resources/UserResource.php:
--------------------------------------------------------------------------------
1 | $this->id,
19 | 'name' => $this->name,
20 | 'email' => $this->email,
21 | 'emailVerifiedAt' => $this->email_verified_at,
22 | 'createdAt' => $this->created_at,
23 | 'updatedAt' => $this->updated_at,
24 | ];
25 | }
26 |
27 | /**
28 | * The "data" wrapper that should be applied.
29 | *
30 | * @var string
31 | */
32 | // デフォルトのキー`data`を変更
33 | public static $wrap = 'user';
34 | }
35 |
--------------------------------------------------------------------------------
/backend/app/Http/Responses/RegisterResponse.php:
--------------------------------------------------------------------------------
1 | wantsJson()
23 | ? new JsonResponse(['user' => new UserResource(Auth::user())], 201)
24 | : redirect()->intended(config('fortify.home'));
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/backend/database/factories/TaskCardFactory.php:
--------------------------------------------------------------------------------
1 | faker->dateTimeBetween('-2 years');
25 | $updatedAt = $this->faker->dateTimeBetween($createdAt);
26 |
27 | return [
28 | 'title' => $this->faker->sentence,
29 | 'content' => implode("\n", $this->faker->paragraphs()),
30 | 'created_at' => $createdAt,
31 | 'updated_at' => $updatedAt,
32 | ];
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/backend/database/factories/TaskListFactory.php:
--------------------------------------------------------------------------------
1 | faker->dateTimeBetween('-2 years');
25 | $updatedAt = $this->faker->dateTimeBetween($createdAt);
26 |
27 | return [
28 | 'title' => $this->faker->sentence,
29 | 'description' => implode("\n", $this->faker->paragraphs()),
30 | 'created_at' => $createdAt,
31 | 'updated_at' => $updatedAt,
32 | ];
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/frontend/src/mocks/controllers/auth/updateProfile.ts:
--------------------------------------------------------------------------------
1 | import { UpdateProfileRequest, UpdateProfileResponse } from 'store/thunks/auth';
2 | import { db, UserDocument } from 'mocks/models';
3 |
4 | type UpdateProfileProps = {
5 | currentUser: UserDocument;
6 | request: UpdateProfileRequest;
7 | };
8 |
9 | export const update = (props: UpdateProfileProps) => {
10 | const { currentUser, request } = props;
11 | const IsEmailUpdated = currentUser.email !== request.email;
12 | const newUserDoc: UserDocument = {
13 | ...currentUser,
14 | name: request.name || currentUser.name,
15 | email: request.email || currentUser.email,
16 | emailVerifiedAt: IsEmailUpdated ? null : currentUser.emailVerifiedAt,
17 | updatedAt: new Date(),
18 | };
19 |
20 | db.update('users', newUserDoc);
21 |
22 | const response: UpdateProfileResponse = {
23 | name: newUserDoc.name,
24 | email: newUserDoc.email,
25 | };
26 |
27 | return response;
28 | };
29 |
--------------------------------------------------------------------------------
/backend/database/factories/TaskBoardFactory.php:
--------------------------------------------------------------------------------
1 | faker->dateTimeBetween('-2 years');
25 | $updatedAt = $this->faker->dateTimeBetween($createdAt);
26 |
27 | return [
28 | 'title' => $this->faker->sentence,
29 | 'description' => implode("\n", $this->faker->paragraphs()),
30 | 'created_at' => $createdAt,
31 | 'updated_at' => $updatedAt,
32 | ];
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/backend/app/Models/TaskList.php:
--------------------------------------------------------------------------------
1 | id = (string) Str::uuid();
23 | });
24 | }
25 |
26 | public function user()
27 | {
28 | return $this->belongsTo(User::class);
29 | }
30 |
31 | public function taskBoard()
32 | {
33 | return $this->belongsTo(TaskBoard::class);
34 | }
35 |
36 | public function taskCards()
37 | {
38 | return $this->hasMany(TaskCard::class);
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/frontend/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment, useEffect } from 'react';
2 |
3 | import { useHistory } from 'react-router-dom';
4 |
5 | import { isSignedIn } from 'utils/auth';
6 | import { useAppSelector } from 'utils/hooks';
7 | import { BaseLayout } from 'layouts';
8 | import { Hero, Features } from 'components/home/LandingPage';
9 |
10 | const LandingPage = () => (
11 |
12 |
13 |
14 |
15 | );
16 |
17 | const renderHome = () => {
18 | if (isSignedIn()) {
19 | // return ;
20 | } else {
21 | return ;
22 | }
23 | };
24 |
25 | const Home = () => {
26 | const history = useHistory();
27 | const userId = useAppSelector((state) => state.auth.user?.id);
28 |
29 | useEffect(() => {
30 | isSignedIn() && history.replace(`users/${userId}/boards`);
31 | }, [history, userId]);
32 |
33 | return {renderHome()};
34 | };
35 |
36 | export default Home;
37 |
--------------------------------------------------------------------------------
/frontend/src/templates/index.ts:
--------------------------------------------------------------------------------
1 | export { default as AlertMessage } from './AlertMessage';
2 | export { default as AlertButton } from './AlertButton';
3 | export { default as DatetimeInput } from './DatetimeInput';
4 | export { default as LabeledCheckbox } from './LabeledCheckbox';
5 | export { default as LabeledSelect } from './LabeledSelect';
6 | export { default as Markdown } from './Markdown';
7 | export { default as MarkdownEditor } from './MarkdownEditor';
8 | export { default as PopoverControl } from './PopoverControl';
9 | export { default as SubmitButton } from './SubmitButton';
10 | export { default as LinkWrapper } from './LinkWrapper';
11 | export { default as LinkButton } from './LinkButton';
12 | export { default as ScrolledDiv } from './ScrolledDiv';
13 | export { default as ScrolledGridContainer } from './ScrolledGridContainer';
14 | export { default as ScrolledTypography } from './ScrolledTypography';
15 |
16 | // Task
17 | export { default as DeleteTaskDialog } from './DeleteTaskDialog';
18 |
--------------------------------------------------------------------------------
/backend/config/cors.php:
--------------------------------------------------------------------------------
1 | ['*'],
18 |
19 | 'allowed_methods' => ['*'],
20 |
21 | 'allowed_origins' => ['*'],
22 |
23 | 'allowed_origins_patterns' => [],
24 |
25 | 'allowed_headers' => ['*'],
26 |
27 | 'exposed_headers' => [],
28 |
29 | 'max_age' => 0,
30 |
31 | 'supports_credentials' => true, // Access-Control-Allow-Credentials: true
32 | ];
33 |
--------------------------------------------------------------------------------
/backend/resources/js/bootstrap.js:
--------------------------------------------------------------------------------
1 | window._ = require('lodash');
2 |
3 | /**
4 | * We'll load the axios HTTP library which allows us to easily issue requests
5 | * to our Laravel back-end. This library automatically handles sending the
6 | * CSRF token as a header based on the value of the "XSRF" token cookie.
7 | */
8 |
9 | window.axios = require('axios');
10 |
11 | window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
12 |
13 | /**
14 | * Echo exposes an expressive API for subscribing to channels and listening
15 | * for events that are broadcast by Laravel. Echo and event broadcasting
16 | * allows your team to easily build robust real-time web applications.
17 | */
18 |
19 | // import Echo from 'laravel-echo';
20 |
21 | // window.Pusher = require('pusher-js');
22 |
23 | // window.Echo = new Echo({
24 | // broadcaster: 'pusher',
25 | // key: process.env.MIX_PUSHER_APP_KEY,
26 | // cluster: process.env.MIX_PUSHER_APP_CLUSTER,
27 | // forceTLS: true
28 | // });
29 |
--------------------------------------------------------------------------------
/backend/app/Http/Middleware/Authenticate.php:
--------------------------------------------------------------------------------
1 | uuid('id')->primary();
18 | $table->string('name');
19 | $table->string('email')->unique();
20 | $table->timestamp('email_verified_at')->nullable();
21 | $table->string('password');
22 | $table->rememberToken();
23 | $table->timestamps();
24 | });
25 | }
26 |
27 | /**
28 | * Reverse the migrations.
29 | *
30 | * @return void
31 | */
32 | public function down()
33 | {
34 | Schema::dropIfExists('users');
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/auth/resetPassword.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { GET_CSRF_TOKEN_PATH, RESET_PASSWORD_PATH } from 'config/api';
4 | import { apiClient } from 'utils/api';
5 | import { AsyncThunkConfig } from 'store/thunks/config';
6 | import { makeRejectValue } from 'store/thunks/utils';
7 |
8 | export type ResetPasswordResponse = void;
9 |
10 | export type ResetPasswordRequest = {
11 | email: string;
12 | password: string;
13 | password_confirmation: string;
14 | token: string;
15 | };
16 |
17 | export const resetPassword = createAsyncThunk<
18 | ResetPasswordResponse,
19 | ResetPasswordRequest,
20 | AsyncThunkConfig
21 | >('auth/resetPassword', async (payload, thunkApi) => {
22 | try {
23 | // 正常時は`200`バリデーションエラー時は`422`
24 | await apiClient({ apiRoute: false }).get(GET_CSRF_TOKEN_PATH);
25 | await apiClient().post(RESET_PASSWORD_PATH, payload);
26 | } catch (error) {
27 | return thunkApi.rejectWithValue(makeRejectValue(error));
28 | }
29 | });
30 |
--------------------------------------------------------------------------------
/backend/database/migrations/2019_08_19_000000_create_failed_jobs_table.php:
--------------------------------------------------------------------------------
1 | id();
18 | $table->string('uuid')->unique();
19 | $table->text('connection');
20 | $table->text('queue');
21 | $table->longText('payload');
22 | $table->longText('exception');
23 | $table->timestamp('failed_at')->useCurrent();
24 | });
25 | }
26 |
27 | /**
28 | * Reverse the migrations.
29 | *
30 | * @return void
31 | */
32 | public function down()
33 | {
34 | Schema::dropIfExists('failed_jobs');
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/frontend/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
16 |
20 |
21 |
22 | %REACT_APP_APP_NAME%
23 |
24 |
25 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/auth/signInWithEmail.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { GET_CSRF_TOKEN_PATH, SIGNIN_PATH } from 'config/api';
4 | import { User } from 'models/User';
5 | import { apiClient } from 'utils/api';
6 | import { AsyncThunkConfig } from 'store/thunks/config';
7 | import { makeRejectValue } from 'store/thunks/utils';
8 |
9 | export type SignInResponse = {
10 | user: User;
11 | verified?: true;
12 | };
13 |
14 | export type SignInRequest = {
15 | email: string;
16 | password: string;
17 | remember?: string;
18 | };
19 |
20 | export const signInWithEmail = createAsyncThunk<
21 | SignInResponse,
22 | SignInRequest,
23 | AsyncThunkConfig
24 | >('auth/signInWithEmail', async (payload, thunkApi) => {
25 | try {
26 | await apiClient({ apiRoute: false }).get(GET_CSRF_TOKEN_PATH);
27 | const response = await apiClient().post(SIGNIN_PATH, payload);
28 | return response?.data;
29 | } catch (error) {
30 | return thunkApi.rejectWithValue(makeRejectValue(error));
31 | }
32 | });
33 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/boards/fetchTaskBoard.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { TaskBoard } from 'models';
4 | import { apiClient, makePath } from 'utils/api';
5 | import { AsyncThunkConfig } from 'store/thunks/config';
6 | import { makeRejectValue } from 'store/thunks/utils';
7 |
8 | export type FetchTaskBoardResponse = {
9 | data: TaskBoard;
10 | };
11 |
12 | export type FetchTaskBoardRequest = {
13 | userId: string;
14 | boardId: string;
15 | };
16 |
17 | export const fetchTaskBoard = createAsyncThunk<
18 | FetchTaskBoardResponse,
19 | FetchTaskBoardRequest,
20 | AsyncThunkConfig
21 | >('boards/fetchTaskBoard', async (payload, thunkApi) => {
22 | const { userId, boardId } = payload;
23 | const path = makePath(['users', userId], ['task-boards', boardId]);
24 |
25 | try {
26 | const response = await apiClient().get(path);
27 | return response?.data;
28 | } catch (error) {
29 | return thunkApi.rejectWithValue(makeRejectValue(error));
30 | }
31 | });
32 |
33 | export default fetchTaskBoard;
34 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/boards/createTaskBoard.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { TaskBoard } from 'models';
4 | import { apiClient, makePath } from 'utils/api';
5 | import { AsyncThunkConfig } from 'store/thunks/config';
6 | import { makeRejectValue } from 'store/thunks/utils';
7 |
8 | export type CreateTaskBoardResponse = {
9 | data: TaskBoard;
10 | };
11 |
12 | export type CreateTaskBoardRequest = Pick &
13 | Partial>;
14 |
15 | export const createTaskBoard = createAsyncThunk<
16 | CreateTaskBoardResponse,
17 | CreateTaskBoardRequest,
18 | AsyncThunkConfig
19 | >('boards/createTaskBoard', async (payload, thunkApi) => {
20 | const userId = String(thunkApi.getState().auth.user?.id);
21 | const path = makePath(['users', userId], ['task-boards']);
22 |
23 | try {
24 | const response = await apiClient().post(path, payload);
25 | return response?.data;
26 | } catch (error) {
27 | return thunkApi.rejectWithValue(makeRejectValue(error));
28 | }
29 | });
30 |
--------------------------------------------------------------------------------
/backend/app/Models/TaskCard.php:
--------------------------------------------------------------------------------
1 | 'datetime',
22 | 'done' => 'boolean',
23 | ];
24 |
25 | protected static function booted()
26 | {
27 | static::creating(function (self $taskCard) {
28 | $taskCard->id = (string) Str::uuid();
29 | });
30 | }
31 |
32 | public function user()
33 | {
34 | return $this->belongsTo(User::class);
35 | }
36 |
37 | public function taskList()
38 | {
39 | return $this->belongsTo(TaskList::class);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/lists/createTaskList.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { TaskList } from 'models';
4 | import { apiClient, makePath } from 'utils/api';
5 | import { AsyncThunkConfig } from 'store/thunks/config';
6 | import { makeRejectValue } from 'store/thunks/utils';
7 |
8 | export type CreateTaskListResponse = {
9 | data: TaskList;
10 | };
11 |
12 | export type CreateTaskListRequest = Pick &
13 | Partial>;
14 |
15 | export const createTaskList = createAsyncThunk<
16 | CreateTaskListResponse,
17 | Pick & CreateTaskListRequest,
18 | AsyncThunkConfig
19 | >('lists/createTaskList', async (payload, thunkApi) => {
20 | const { boardId, ...mainPayload } = payload;
21 | const path = makePath(['task-boards', boardId], ['task-lists']);
22 |
23 | try {
24 | const response = await apiClient().post(path, mainPayload);
25 | return response?.data;
26 | } catch (error) {
27 | return thunkApi.rejectWithValue(makeRejectValue(error));
28 | }
29 | });
30 |
--------------------------------------------------------------------------------
/backend/app/Console/Kernel.php:
--------------------------------------------------------------------------------
1 | job(new DeleteUnverifiedUser())->cron('* 0 * * *');
29 | }
30 |
31 | /**
32 | * Register the commands for the application.
33 | *
34 | * @return void
35 | */
36 | protected function commands()
37 | {
38 | $this->load(__DIR__ . '/Commands');
39 |
40 | require base_path('routes/console.php');
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/backend/config/services.php:
--------------------------------------------------------------------------------
1 | [
17 | 'domain' => env('MAILGUN_DOMAIN'),
18 | 'secret' => env('MAILGUN_SECRET'),
19 | 'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'),
20 | ],
21 |
22 | 'postmark' => [
23 | 'token' => env('POSTMARK_TOKEN'),
24 | ],
25 |
26 | 'ses' => [
27 | 'key' => env('AWS_ACCESS_KEY_ID'),
28 | 'secret' => env('AWS_SECRET_ACCESS_KEY'),
29 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
30 | ],
31 | ];
32 |
--------------------------------------------------------------------------------
/backend/tests/TestCase.php:
--------------------------------------------------------------------------------
1 | routePrefix = config('fortify.prefix');
21 |
22 | $this->guestUser = User::factory()->create([
23 | 'name' => config('fortify.guest.name'),
24 | 'email' => config('fortify.guest.email'),
25 | ]);
26 |
27 | $this->otherUser = User::factory()->create();
28 | }
29 |
30 | protected function login(User $user)
31 | {
32 | $response = $this->postJson($this->routePrefix . '/login', [
33 | 'email' => $user->email,
34 | 'password' => 'password',
35 | ]);
36 | $response->assertOk(); // 200
37 | return $response;
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/terraform/.gitignore:
--------------------------------------------------------------------------------
1 | # https://github.com/github/gitignore/blob/master/Terraform.gitignore
2 |
3 | # Local .terraform directories
4 | **/.terraform/*
5 |
6 | # .tfstate files
7 | *.tfstate
8 | *.tfstate.*
9 |
10 | # Crash log files
11 | crash.log
12 |
13 | # Exclude all .tfvars files, which are likely to contain sentitive data, such as
14 | # password, private keys, and other secrets. These should not be part of version
15 | # control as they are data points which are potentially sensitive and subject
16 | # to change depending on the environment.
17 | #
18 | *.tfvars
19 |
20 | # Ignore override files as they are usually used to override resources locally and so
21 | # are not checked in
22 | override.tf
23 | override.tf.json
24 | *_override.tf
25 | *_override.tf.json
26 |
27 | # Include override files you do wish to add to version control using negated pattern
28 | #
29 | # !example_override.tf
30 |
31 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
32 | # example: *tfplan*
33 |
34 | # Ignore CLI configuration files
35 | .terraformrc
36 | terraform.rc
37 |
--------------------------------------------------------------------------------
/frontend/src/config/mdEditor.ts:
--------------------------------------------------------------------------------
1 | import { commands } from '@uiw/react-md-editor';
2 |
3 | /** @see https://uiwjs.github.io/react-md-editor/#custom-toolbars */
4 | export const titleCommand = commands.group(
5 | [
6 | commands.title1,
7 | commands.title2,
8 | commands.title3,
9 | commands.title4,
10 | commands.title5,
11 | commands.title6,
12 | ],
13 | {
14 | name: 'title',
15 | groupName: 'title',
16 | buttonProps: {
17 | 'aria-label': 'Insert title',
18 | title: 'Insert title',
19 | },
20 | }
21 | );
22 |
23 | // デフォルトの並び順は以下を参照
24 | // node_modules/@uiw/react-md-editor/lib/commands/getCommands
25 | export const mdCommands: commands.ICommand[] = [
26 | commands.bold,
27 | commands.italic,
28 | commands.strikethrough,
29 | commands.hr,
30 | titleCommand,
31 | commands.divider,
32 | commands.link,
33 | commands.quote,
34 | commands.code,
35 | commands.codeBlock,
36 | commands.image,
37 | commands.divider,
38 | commands.unorderedListCommand,
39 | commands.orderedListCommand,
40 | commands.checkedListCommand,
41 | ];
42 |
--------------------------------------------------------------------------------
/frontend/src/utils/dnd.ts:
--------------------------------------------------------------------------------
1 | import { DocumentBase } from 'models';
2 |
3 | export const draggableItem = {
4 | card: 'card',
5 | };
6 |
7 | /** @see https://react-dnd.github.io/react-dnd/docs/api/use-drag#specification-object-members */
8 | export type DragItem = {
9 | id: string;
10 | listId: string;
11 | index: number;
12 | listIndex: number;
13 | };
14 |
15 | /** `Document`の`id`とその順番(index)の対応関係を示すオブジェクト */
16 | export type IndexMap = { [docId: string]: number };
17 |
18 | /** `Document`配列から`IndexMap`を作成 */
19 | export const makeIndexMap = (docs: T[]) => {
20 | const indexMap: IndexMap = {};
21 |
22 | docs.forEach((doc, i) => {
23 | indexMap[doc.id] = i;
24 | });
25 |
26 | return indexMap;
27 | };
28 |
29 | /** `Document`配列に`index`プロパティを追加 */
30 | export const makeDocsWithIndex = (
31 | docs: T[],
32 | indexMap: IndexMap
33 | ) => {
34 | return docs.map((doc) => {
35 | const docWithIndex = {
36 | ...doc,
37 | index: indexMap ? indexMap[doc.id] : 0,
38 | };
39 | return docWithIndex;
40 | });
41 | };
42 |
--------------------------------------------------------------------------------
/backend/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php:
--------------------------------------------------------------------------------
1 | bigIncrements('id');
18 | $table->morphs('tokenable');
19 | $table->string('name');
20 | $table->string('token', 64)->unique();
21 | $table->text('abilities')->nullable();
22 | $table->timestamp('last_used_at')->nullable();
23 | $table->timestamps();
24 | });
25 | }
26 |
27 | /**
28 | * Reverse the migrations.
29 | *
30 | * @return void
31 | */
32 | public function down()
33 | {
34 | Schema::dropIfExists('personal_access_tokens');
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/frontend/src/theme/overrides.ts:
--------------------------------------------------------------------------------
1 | import { Overrides } from '@material-ui/core/styles/overrides';
2 |
3 | const overrides: Overrides = {
4 | MuiCssBaseline: {
5 | // GlobalCSS
6 | '@global': {
7 | a: {
8 | color: '#1a73e8',
9 | textDecoration: 'none',
10 | '&:hover': {
11 | color: '#ffa133',
12 | textDecoration: 'underline',
13 | },
14 | },
15 | },
16 | },
17 | MuiButtonBase: {
18 | root: {
19 | '& *': { pointerEvents: 'none' }, // `event.target`として捕捉されるのを防ぐ
20 | },
21 | },
22 | MuiButton: { root: { textTransform: 'unset' } }, // Buttonテキストの大文字変換設定を解除
23 | MuiList: { dense: { paddingTop: '4px', paddingBottom: '4px' } },
24 | MuiListItemIcon: { root: { minWidth: undefined, paddingRight: '16px' } },
25 | MuiListItemText: {
26 | primary: {
27 | whiteSpace: 'nowrap',
28 | overflow: 'hidden',
29 | textOverflow: 'ellipsis',
30 | },
31 | },
32 | MuiTypography: {
33 | gutterBottom: {
34 | '&:not(div)': { marginBottom: '1.2rem' },
35 | },
36 | },
37 | };
38 |
39 | export default overrides;
40 |
--------------------------------------------------------------------------------
/frontend/src/utils/sort.ts:
--------------------------------------------------------------------------------
1 | export type SortOperation = {
2 | column: keyof T;
3 | direction?: 'asc' | 'desc';
4 | };
5 |
6 | /**
7 | * `Array.sort()`の比較関数として利用
8 | * 1. `number`型、`Date`型の場合は数値比較
9 | * 2. 上記以外の型は`string`型に変換して比較
10 | *
11 | * @see https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#description
12 | * */
13 | export const compare = (
14 | a: T,
15 | b: T,
16 | key: keyof T,
17 | direction?: 'asc' | 'desc'
18 | ) => {
19 | const process = () => {
20 | const valueA = a[key];
21 | const valueB = b[key];
22 |
23 | if (typeof valueA === 'number' && typeof valueB === 'number')
24 | return valueA - valueB;
25 | else if (valueA instanceof Date && valueB instanceof Date)
26 | return valueA.valueOf() - valueB.valueOf();
27 |
28 | const stringValueA = String(valueA);
29 | const stringValueB = String(valueB);
30 |
31 | if (stringValueA < stringValueB) return -1;
32 | else if (stringValueA > stringValueB) return 1;
33 | else return 0;
34 | };
35 |
36 | return direction === 'desc' ? -process() : process();
37 | };
38 |
--------------------------------------------------------------------------------
/backend/database/migrations/2021_06_21_184049_add_task_list_id_columns_to_task_cards_table.php:
--------------------------------------------------------------------------------
1 | foreignUuid('task_list_id')
19 | ->comment('Task List ID')
20 | ->constrained()
21 | ->cascadeOnUpdate()
22 | ->cascadeOnDelete();
23 | });
24 | }
25 |
26 | /**
27 | * Reverse the migrations.
28 | *
29 | * @return void
30 | */
31 | public function down()
32 | {
33 | Schema::table('task_cards', function (Blueprint $table) {
34 | $table->dropForeign(['task_list_id']);
35 | $table->dropColumn('task_list_id');
36 | });
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/boards/fetchTaskBoards.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { TaskBoard } from 'models';
4 | import { apiClient, makePath, PaginationResponse } from 'utils/api';
5 | import { AsyncThunkConfig } from 'store/thunks/config';
6 | import { makeRejectValue } from 'store/thunks/utils';
7 |
8 | export type FetchTaskBoardsResponse = PaginationResponse;
9 |
10 | export type FetchTaskBoardsRequest = {
11 | userId: string;
12 | page?: string;
13 | };
14 |
15 | export const fetchTaskBoards = createAsyncThunk<
16 | FetchTaskBoardsResponse,
17 | FetchTaskBoardsRequest,
18 | AsyncThunkConfig
19 | >('boards/fetchTaskBoards', async (payload, thunkApi) => {
20 | const { userId, page } = payload;
21 | const path = makePath(['users', userId], ['task-boards']);
22 |
23 | try {
24 | const response = await apiClient().get(path, {
25 | params: { page: page || undefined },
26 | });
27 | return response?.data;
28 | } catch (error) {
29 | return thunkApi.rejectWithValue(makeRejectValue(error));
30 | }
31 | });
32 |
33 | export default fetchTaskBoards;
34 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/lists/updateTaskList.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { TaskList } from 'models';
4 | import { apiClient, makePath } from 'utils/api';
5 | import { AsyncThunkConfig } from 'store/thunks/config';
6 | import { makeRejectValue } from 'store/thunks/utils';
7 |
8 | export type UpdateTaskListResponse = {
9 | data: TaskList;
10 | };
11 |
12 | export type UpdateTaskListRequest = Partial<
13 | Pick
14 | >;
15 |
16 | export type UpdateTaskListArg = Pick &
17 | UpdateTaskListRequest;
18 |
19 | export const updateTaskList = createAsyncThunk<
20 | UpdateTaskListResponse,
21 | UpdateTaskListArg,
22 | AsyncThunkConfig
23 | >('lists/updateTaskList', async (payload, thunkApi) => {
24 | const { id, boardId, ...requestBody } = payload;
25 | const path = makePath(['task-boards', boardId], ['task-lists', id]);
26 |
27 | try {
28 | const response = await apiClient().patch(path, requestBody);
29 | return response?.data;
30 | } catch (error) {
31 | return thunkApi.rejectWithValue(makeRejectValue(error));
32 | }
33 | });
34 |
--------------------------------------------------------------------------------
/backend/routes/api/v1.php:
--------------------------------------------------------------------------------
1 | group(function () {
13 | /*
14 | |--------------------------------------------------------------------------
15 | | Tasks
16 | |--------------------------------------------------------------------------
17 | | https://laravel.com/docs/9.x/controllers#restful-scoping-resource-routes
18 | */
19 |
20 | Route::apiResource('users.task-boards', TaskBoardController::class)
21 | ->except([])
22 | ->scoped();
23 |
24 | Route::apiResource('task-boards.task-lists', TaskListController::class)
25 | ->only(['store', 'update', 'destroy', 'show'])
26 | ->scoped();
27 |
28 | Route::apiResource('task-lists.task-cards', TaskCardController::class)
29 | ->only(['store', 'update', 'destroy'])
30 | ->scoped();
31 | });
32 |
--------------------------------------------------------------------------------
/backend/app/Http/Requests/UpdateTaskListRequest.php:
--------------------------------------------------------------------------------
1 |
21 | */
22 | public function validationData()
23 | {
24 | $requests = [];
25 |
26 | foreach ($this->all() as $key => $value) {
27 | $requests[Str::snake($key)] = $value;
28 | }
29 |
30 | return $requests;
31 | }
32 |
33 | /**
34 | * Get the validation rules that apply to the request.
35 | *
36 | * @return array
37 | */
38 | public function rules()
39 | {
40 | return [
41 | 'title' => 'string|min:1|max:255',
42 | 'description' => 'nullable|string|min:1|max:2000',
43 | ];
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/backend/config/view.php:
--------------------------------------------------------------------------------
1 | [resource_path('views')],
16 |
17 | /*
18 | |--------------------------------------------------------------------------
19 | | Compiled View Path
20 | |--------------------------------------------------------------------------
21 | |
22 | | This option determines where all the compiled Blade templates will be
23 | | stored for your application. Typically, this is within the storage
24 | | directory. However, as usual, you are free to change this value.
25 | |
26 | */
27 |
28 | 'compiled' => env(
29 | 'VIEW_COMPILED_PATH',
30 | realpath(storage_path('framework/views')),
31 | ),
32 | ];
33 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/cards/createTaskCard.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { TaskCard } from 'models';
4 | import { apiClient, makePath } from 'utils/api';
5 | import { AsyncThunkConfig } from 'store/thunks/config';
6 | import { makeRejectValue } from 'store/thunks/utils';
7 |
8 | export type CreateTaskCardResponse = {
9 | data: TaskCard;
10 | };
11 |
12 | export type CreateTaskCardRequest = Pick &
13 | Partial>;
14 |
15 | export const createTaskCard = createAsyncThunk<
16 | { boardId: string } & CreateTaskCardResponse,
17 | { boardId: string } & Pick & CreateTaskCardRequest,
18 | AsyncThunkConfig
19 | >('cards/createTaskCard', async (payload, thunkApi) => {
20 | const { boardId, listId, ...requestBody } = payload;
21 | const path = makePath(['task-lists', listId], ['task-cards']);
22 |
23 | try {
24 | const response = await apiClient().post(path, requestBody);
25 |
26 | return { ...response?.data, boardId: payload.boardId };
27 | } catch (error) {
28 | return thunkApi.rejectWithValue(makeRejectValue(error));
29 | }
30 | });
31 |
--------------------------------------------------------------------------------
/backend/app/Http/Requests/StoreTaskListRequest.php:
--------------------------------------------------------------------------------
1 |
21 | */
22 | public function validationData()
23 | {
24 | $requests = [];
25 |
26 | foreach ($this->all() as $key => $value) {
27 | $requests[Str::snake($key)] = $value;
28 | }
29 |
30 | return $requests;
31 | }
32 |
33 | /**
34 | * Get the validation rules that apply to the request.
35 | *
36 | * @return array
37 | */
38 | public function rules()
39 | {
40 | return [
41 | 'title' => 'required|string|min:1|max:255',
42 | 'description' => 'nullable|string|min:1|max:2000',
43 | ];
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/backend/database/migrations/2021_06_17_061724_create_task_boards_table.php:
--------------------------------------------------------------------------------
1 | comment('Group of task lists');
18 |
19 | $table->uuid('id')->primary();
20 | $table
21 | ->foreignUuid('user_id')
22 | ->comment('User ID')
23 | ->constrained()
24 | ->cascadeOnUpdate()
25 | ->cascadeOnDelete();
26 | $table->string('title', 255);
27 | $table->string('description', 2000)->nullable();
28 | $table->timestamps();
29 | });
30 | }
31 |
32 | /**
33 | * Reverse the migrations.
34 | *
35 | * @return void
36 | */
37 | public function down()
38 | {
39 | Schema::dropIfExists('task_boards');
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/lists/destroyTaskList.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { TaskList } from 'models';
4 | import { apiClient, makePath } from 'utils/api';
5 | import { AsyncThunkConfig } from 'store/thunks/config';
6 | import { makeRejectValue } from 'store/thunks/utils';
7 |
8 | export type DestroyTaskListResponse = {
9 | data: TaskList;
10 | };
11 |
12 | export const destroyTaskList = createAsyncThunk<
13 | DestroyTaskListResponse,
14 | Pick,
15 | AsyncThunkConfig
16 | >('lists/destroyTaskList', async (payload, thunkApi) => {
17 | const { setFlash } = await import('store/slices/authSlice');
18 |
19 | const { id, boardId } = payload;
20 | const path = makePath(['task-boards', boardId], ['task-lists', id]);
21 |
22 | try {
23 | const response = await apiClient().delete(path);
24 |
25 | thunkApi.dispatch(
26 | setFlash({
27 | type: 'warning',
28 | message: `${response.data.data.title} は削除されました`,
29 | })
30 | );
31 |
32 | return response?.data;
33 | } catch (error) {
34 | return thunkApi.rejectWithValue(makeRejectValue(error));
35 | }
36 | });
37 |
--------------------------------------------------------------------------------
/backend/database/migrations/2021_08_03_182417_add_index_columns_to_task_boards_table.php:
--------------------------------------------------------------------------------
1 | json('list_index_map')
19 | ->comment('Object with a List ID key and its sequence value')
20 | ->nullable();
21 | $table
22 | ->json('card_index_map')
23 | ->comment('Object with a Card ID key and its sequence value')
24 | ->nullable();
25 | });
26 | }
27 |
28 | /**
29 | * Reverse the migrations.
30 | *
31 | * @return void
32 | */
33 | public function down()
34 | {
35 | Schema::table('task_boards', function (Blueprint $table) {
36 | $table->dropColumn(['list_index_map', 'card_index_map']);
37 | });
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/boards/updateTaskBoard.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { TaskBoard } from 'models';
4 | import { apiClient, makePath } from 'utils/api';
5 | import { AsyncThunkConfig } from 'store/thunks/config';
6 | import { makeRejectValue } from 'store/thunks/utils';
7 |
8 | export type UpdateTaskBoardResponse = {
9 | data: TaskBoard;
10 | };
11 |
12 | export type UpdateTaskBoardRequest = Partial<
13 | Pick
14 | >;
15 |
16 | type UpdateTaskBoardArg = Pick & UpdateTaskBoardRequest;
17 |
18 | export const updateTaskBoard = createAsyncThunk<
19 | UpdateTaskBoardResponse,
20 | UpdateTaskBoardArg,
21 | AsyncThunkConfig
22 | >('boards/updateTaskBoard', async (payload, thunkApi) => {
23 | const { id, ...mainPayload } = payload;
24 | const userId = String(thunkApi.getState().auth.user?.id);
25 | const path = makePath(['users', userId], ['task-boards', id]);
26 |
27 | try {
28 | const response = await apiClient().patch(path, mainPayload);
29 | return response?.data;
30 | } catch (error) {
31 | return thunkApi.rejectWithValue(makeRejectValue(error));
32 | }
33 | });
34 |
--------------------------------------------------------------------------------
/backend/database/migrations/2014_10_12_200000_add_two_factor_columns_to_users_table.php:
--------------------------------------------------------------------------------
1 | text('two_factor_secret')
19 | ->after('password')
20 | ->nullable();
21 |
22 | $table
23 | ->text('two_factor_recovery_codes')
24 | ->after('two_factor_secret')
25 | ->nullable();
26 | });
27 | }
28 |
29 | /**
30 | * Reverse the migrations.
31 | *
32 | * @return void
33 | */
34 | public function down()
35 | {
36 | Schema::table('users', function (Blueprint $table) {
37 | $table->dropColumn(
38 | 'two_factor_secret',
39 | 'two_factor_recovery_codes',
40 | );
41 | });
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/frontend/src/mocks/controllers/taskListController.ts:
--------------------------------------------------------------------------------
1 | import { RestRequest } from 'msw';
2 |
3 | import { TaskList } from 'models';
4 | import {
5 | CreateTaskListRequest,
6 | UpdateTaskListRequest,
7 | } from 'store/thunks/lists';
8 | import { db, TaskListDocument } from 'mocks/models';
9 |
10 | export const store = (req: RestRequest) => {
11 | const newList = db.create('taskLists', {
12 | ...({} as TaskListDocument),
13 | boardId: req.params.boardId,
14 | ...req.body,
15 | });
16 |
17 | const response: TaskList = { ...newList, cards: [] };
18 |
19 | return response;
20 | };
21 |
22 | export const update = (req: RestRequest) => {
23 | const list = db.where('taskLists', 'id', req.params.listId)[0];
24 |
25 | if (!list) return;
26 |
27 | const updated = db.update('taskLists', { ...list, ...req.body });
28 | const response: TaskList = { ...updated, cards: [] };
29 |
30 | return response;
31 | };
32 |
33 | export const destroy = (req: RestRequest) => {
34 | const deleted = db.remove('taskLists', req.params.listId);
35 |
36 | if (!deleted) return;
37 |
38 | const response: TaskList = { ...deleted, cards: [] };
39 |
40 | return response;
41 | };
42 |
--------------------------------------------------------------------------------
/backend/app/Actions/Fortify/CreateNewUser.php:
--------------------------------------------------------------------------------
1 | ['required', 'string', 'max:255'],
25 | 'email' => [
26 | 'required',
27 | 'string',
28 | 'email',
29 | 'max:255',
30 | Rule::unique(User::class),
31 | ],
32 | 'password' => $this->passwordRules(),
33 | ])->validate();
34 |
35 | return User::create([
36 | 'name' => $input['name'],
37 | 'email' => $input['email'],
38 | 'password' => Hash::make($input['password']),
39 | ]);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/frontend/src/templates/LabeledCheckbox.tsx:
--------------------------------------------------------------------------------
1 | import { makeStyles, createStyles, Theme } from '@material-ui/core/styles';
2 | import { FormControlLabel, Checkbox, CheckboxProps } from '@material-ui/core';
3 |
4 | const useStyles = makeStyles((theme: Theme) =>
5 | createStyles({
6 | label: { color: theme.palette.text.hint },
7 | })
8 | );
9 |
10 | type LabeledCheckboxProps = {
11 | label: string;
12 | checked: boolean;
13 | setChecked: React.Dispatch>;
14 | } & CheckboxProps;
15 |
16 | const LabeledCheckbox: React.FC = (props) => {
17 | const { label, checked, setChecked, ...checkboxProps } = props;
18 | const classes = useStyles();
19 |
20 | const handleChange = () => {
21 | props.setChecked(!props.checked);
22 | };
23 |
24 | return (
25 |
26 |
36 | }
37 | />
38 |
39 | );
40 | };
41 |
42 | export default LabeledCheckbox;
43 |
--------------------------------------------------------------------------------
/frontend/src/layouts/FlashNotification.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 |
3 | import Snackbar from '@material-ui/core/Snackbar';
4 | import { Alert } from '@material-ui/lab';
5 |
6 | import { useDeepEqualSelector } from 'utils/hooks';
7 |
8 | const FlashNotification = () => {
9 | const [open, setOpen] = useState(false);
10 | const flash = useDeepEqualSelector((state) => state.auth.flash);
11 | const lastFlash = flash.slice(-1)[0];
12 |
13 | // `flash`(store) の変更を監視
14 | useEffect(() => setOpen(true), [flash]);
15 |
16 | const handleClose = (_event?: React.SyntheticEvent, reason?: string) => {
17 | if (reason === 'clickaway') return;
18 | else setOpen(false);
19 | };
20 |
21 | // `flash`が初期値の場合表示しない
22 | if (flash.length === 0) return ;
23 |
24 | return (
25 |
32 |
33 | {lastFlash.message}
34 |
35 |
36 | );
37 | };
38 |
39 | export default FlashNotification;
40 |
--------------------------------------------------------------------------------
/frontend/src/utils/infoBox.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * イベント発生時に`eventState`を指定することで`ClickAwayListener`の適用を免れる
3 | *
4 | * @property shown 既に表示されていることを示す
5 | */
6 | const eventState = { shown: false };
7 |
8 | /** イベントの種類を指定する */
9 | export const activateEventAttr = (type: keyof typeof eventState) => {
10 | eventState[type] = true;
11 | };
12 |
13 | /** 指定したイベントの状態を戻す */
14 | export const deactivateEventAttr = (type: keyof typeof eventState) => {
15 | eventState[type] = false;
16 | };
17 |
18 | /** @returns 既に自身が表示されている場合`true`(`openInfoBox`時に要更新) */
19 | export const isItself = () => eventState.shown;
20 |
21 | /** @returns 別のデータを表示する操作を行った場合`true` */
22 | export const hasChanged = (prev?: T, current?: T) =>
23 | prev?.id !== current?.id;
24 |
25 | /**
26 | * @returns `Button`及び`Popover`状態でのクリックを除外
27 | * @see https://developer.mozilla.org/ja/docs/Web/CSS/pointer-events
28 | */
29 | export const isIgnoredTarget = (eventTarget: HTMLElement) => {
30 | const isButton = () =>
31 | eventTarget.tagName === 'BUTTON' ||
32 | eventTarget.getAttribute('role') === 'button';
33 | const isPopoverDisplayed = () => eventTarget.style['zIndex'] === '-1';
34 | const ignored = isButton() || isPopoverDisplayed();
35 | return ignored;
36 | };
37 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/boards/destroyTaskBoard.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { TaskBoard } from 'models';
4 | import { apiClient, makePath } from 'utils/api';
5 | import { AsyncThunkConfig } from 'store/thunks/config';
6 | import { makeRejectValue } from 'store/thunks/utils';
7 |
8 | export type DestroyTaskBoardResponse = {
9 | data: TaskBoard;
10 | };
11 |
12 | export type DestroyTaskBoardRequest = Pick;
13 |
14 | export const destroyTaskBoard = createAsyncThunk<
15 | DestroyTaskBoardResponse,
16 | DestroyTaskBoardRequest,
17 | AsyncThunkConfig
18 | >('boards/destroyTaskBoard', async (payload, thunkApi) => {
19 | const { setFlash } = await import('store/slices/authSlice');
20 |
21 | const userId = String(thunkApi.getState().auth.user?.id);
22 | const path = makePath(['users', userId], ['task-boards', payload.id]);
23 |
24 | try {
25 | const response = await apiClient().delete(path);
26 | thunkApi.dispatch(
27 | setFlash({
28 | type: 'warning',
29 | message: `${payload.title}は削除されました`,
30 | })
31 | );
32 | return response?.data;
33 | } catch (error) {
34 | return thunkApi.rejectWithValue(makeRejectValue(error));
35 | }
36 | });
37 |
--------------------------------------------------------------------------------
/backend/.env.dev:
--------------------------------------------------------------------------------
1 | # This file is used for Serverless in GitHub Actions.
2 |
3 | # PROJECT=(GitHub Actions Env)
4 | # AWS_DEFAULT_REGION=(GitHub Actions Env)
5 |
6 | APP_NAME=Miwataru
7 | APP_ENV=production
8 | # APP_KEY=(GitHub Secrets)
9 | APP_DEBUG=false
10 | # APP_URL=(Serverless SSM)
11 |
12 | # MIX_ASSET_URL=(Serverless SSM)
13 |
14 | API_ROUTE_PREFIX=/api/
15 | API_VERSION=v1
16 |
17 | LOG_CHANNEL=stderr
18 | LOG_LEVEL=debug
19 |
20 | DB_CONNECTION=mysql
21 | DB_PORT=3306
22 | # DB_HOST=(Serverless SSM)
23 | # DB_DATABASE=(Serverless SSM)
24 | # DB_USERNAME=(Serverless SSM)
25 | # DB_PASSWORD=(Serverless SSM)
26 |
27 | CACHE_DRIVER=dynamodb
28 | SESSION_DRIVER=dynamodb
29 | # SESSION_DOMAIN=(Serverless SSM)
30 | SESSION_LIFETIME=1440
31 | SESSION_SECURE_COOKIE=true
32 |
33 | # SANCTUM_STATEFUL_DOMAINS=(Serverless SSM)
34 | # SPA_URL=(Serverless SSM)
35 |
36 | MAIL_MAILER=smtp
37 | MAIL_HOST=email-smtp.${AWS_DEFAULT_REGION}.amazonaws.com
38 | MAIL_PORT=587
39 | # MAIL_USERNAME=(Serverless SSM)
40 | # MAIL_PASSWORD=(Serverless SSM)
41 | MAIL_ENCRYPTION=tls
42 | # MAIL_FROM_ADDRESS=(Serverless SSM)
43 | MAIL_FROM_NAME="${APP_NAME}"
44 |
45 | # ADMIN_EMAIL=(GitHub Secrets)
46 |
47 | GUEST_NAME=ゲストユーザー
48 | GUEST_EMAIL=test@example.com
49 | GUEST_PASSWORD=password
50 |
--------------------------------------------------------------------------------
/backend/app/Jobs/DeleteUnverifiedUser.php:
--------------------------------------------------------------------------------
1 | DB::delete('delete from users where email_verified_at is null');
36 | $unverified_user = User::where('email_verified_at', null);
37 | Log::debug(
38 | 'App\Jobs\DeleteUnverifiedUser; Deleted User => ' .
39 | $unverified_user->pluck('email', 'id'),
40 | );
41 | $unverified_user->delete();
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/backend/.env.testing:
--------------------------------------------------------------------------------
1 | APP_NAME=Laravel
2 | APP_ENV=testing
3 | APP_KEY= # sail artisan key:generate --env=testing
4 | APP_DEBUG=true
5 | APP_URL=http://backend.test
6 |
7 | API_ROUTE_PREFIX='api/'
8 | API_VERSION='v1'
9 |
10 | LOG_CHANNEL=stack
11 | LOG_LEVEL=debug
12 |
13 | DB_CONNECTION=mysql
14 | DB_HOST=mysql.test # docker-compose.ymlでのサービス名
15 | DB_PORT=3306 # DBコンテナ側ポート
16 | DB_DATABASE=backend
17 | DB_USERNAME=root
18 | DB_PASSWORD=
19 |
20 | BROADCAST_DRIVER=log
21 | CACHE_DRIVER=file
22 | QUEUE_CONNECTION=sync
23 | SESSION_DRIVER=file
24 | SESSION_LIFETIME=120
25 |
26 | MEMCACHED_HOST=127.0.0.1
27 |
28 | REDIS_HOST=127.0.0.1
29 | REDIS_PASSWORD=null
30 | REDIS_PORT=6379
31 |
32 | MAIL_MAILER=smtp
33 | MAIL_HOST=mailhog
34 | MAIL_PORT=1025
35 | MAIL_USERNAME=null
36 | MAIL_PASSWORD=null
37 | MAIL_ENCRYPTION=null
38 | MAIL_FROM_ADDRESS=null
39 | MAIL_FROM_NAME="${APP_NAME}"
40 |
41 | AWS_ACCESS_KEY_ID=
42 | AWS_SECRET_ACCESS_KEY=
43 | AWS_DEFAULT_REGION=us-east-1
44 | AWS_BUCKET=
45 |
46 | PUSHER_APP_ID=
47 | PUSHER_APP_KEY=
48 | PUSHER_APP_SECRET=
49 | PUSHER_APP_CLUSTER=mt1
50 |
51 | MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
52 | MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
53 |
54 | GUEST_NAME='ゲストユーザー'
55 | GUEST_EMAIL=test@example.com
56 | GUEST_PASSWORD=password
57 |
--------------------------------------------------------------------------------
/backend/app/Http/Requests/StoreTaskBoardRequest.php:
--------------------------------------------------------------------------------
1 |
24 | */
25 | public function validationData()
26 | {
27 | $requests = [];
28 |
29 | foreach ($this->all() as $key => $value) {
30 | $requests[Str::snake($key)] = $value;
31 | }
32 |
33 | return $requests;
34 | }
35 |
36 | /**
37 | * Get the validation rules that apply to the request.
38 | *
39 | * @return array
40 | */
41 | public function rules()
42 | {
43 | return [
44 | 'title' => 'required|string|min:1|max:255',
45 | 'description' => 'nullable|string|max:2000',
46 | ];
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/frontend/src/templates/LinkButton.tsx:
--------------------------------------------------------------------------------
1 | import { Link as RouterLink } from 'react-router-dom';
2 |
3 | import { createStyles, makeStyles, Theme } from '@material-ui/core/styles';
4 | import { Button, ButtonProps, ButtonTypeMap } from '@material-ui/core';
5 |
6 | const useStyles = makeStyles((theme: Theme) =>
7 | createStyles({
8 | contained: {
9 | textDecoration: 'none',
10 | '&:hover': { textDecoration: 'none' },
11 | },
12 | containedPrimary: {
13 | '&:hover': { color: theme.palette.primary.contrastText },
14 | },
15 | containedSecondary: {
16 | '&:hover': { color: theme.palette.secondary.contrastText },
17 | },
18 | })
19 | );
20 |
21 | type LinkButtonProps = {
22 | to: string;
23 | } & ButtonProps<
24 | ButtonTypeMap, 'a'>['defaultComponent']
25 | >;
26 |
27 | const LinkButton = (props: LinkButtonProps) => {
28 | const { to, classes, ...buttonProps } = props;
29 | const defaultClasses = useStyles();
30 |
31 | return (
32 |
40 | );
41 | };
42 |
43 | export default LinkButton;
44 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/cards/updateTaskCardRelationships.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 | import { AxiosResponse } from 'axios';
3 |
4 | import { TaskCard } from 'models';
5 | import { apiClient, makePath } from 'utils/api';
6 | import { AsyncThunkConfig } from 'store/thunks/config';
7 | import { makeRejectValue } from 'store/thunks/utils';
8 |
9 | type UpdateTaskCardRelationshipsResponse = Pick;
10 |
11 | type UpdateTaskCardRelationshipsRequest = Pick;
12 |
13 | type UpdateTaskCardRelationshipsArg = {
14 | data: Pick;
15 | body: UpdateTaskCardRelationshipsRequest;
16 | };
17 |
18 | export const updateTaskCardRelationships = createAsyncThunk<
19 | UpdateTaskCardRelationshipsResponse,
20 | UpdateTaskCardRelationshipsArg,
21 | AsyncThunkConfig
22 | >('cards/updateTaskCardRelationships', async (payload, thunkApi) => {
23 | const path = makePath(
24 | ['task-lists', payload.data.listId],
25 | ['task-cards', payload.data.id]
26 | );
27 |
28 | try {
29 | const response = await apiClient().patch(path, payload.body);
30 | return { status: response.status };
31 | } catch (error) {
32 | return thunkApi.rejectWithValue(makeRejectValue(error));
33 | }
34 | });
35 |
--------------------------------------------------------------------------------
/frontend/src/mocks/utils/crypto.ts:
--------------------------------------------------------------------------------
1 | import crypto from 'crypto';
2 |
3 | const password = process.env.REACT_APP_APP_KEY || 'password'.repeat(4);
4 | const salt = crypto.randomBytes(128); // at least 16B recommended
5 | const key =
6 | process.env.NODE_ENV === 'test'
7 | ? crypto.scryptSync(password, salt, 32)
8 | : Buffer.from(password);
9 |
10 | const iv = 'iv'.repeat(8); // lenght is 16, if AES
11 | const algorithm = 'aes-256-cbc';
12 |
13 | export const encrypt = (text: string) => {
14 | try {
15 | const cipher = crypto.createCipheriv(algorithm, key, iv);
16 | let encrypted = cipher.update(text, 'utf8', 'hex');
17 | encrypted += cipher.final('hex');
18 | return encrypted;
19 | } catch (e) {
20 | console.error(e);
21 | throw e;
22 | }
23 | };
24 |
25 | export const decrypt = (text: string) => {
26 | try {
27 | const decipher = crypto.createDecipheriv(algorithm, key, iv);
28 | let decrypted = decipher.update(text, 'hex', 'utf8');
29 | decrypted += decipher.final('utf8');
30 | return decrypted;
31 | } catch (e) {
32 | console.error(e);
33 | throw e;
34 | }
35 | };
36 |
37 | export const digestText = (text: string) => {
38 | const hashHex = crypto.createHash('sha256').update(text).digest('hex');
39 | return hashHex;
40 | };
41 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/auth/createUser.ts:
--------------------------------------------------------------------------------
1 | // `createAsyncThunk` returns a standard Redux thunk action creator.
2 | import { createAsyncThunk } from '@reduxjs/toolkit';
3 |
4 | import { GET_CSRF_TOKEN_PATH, SIGNUP_PATH } from 'config/api';
5 | import { User } from 'models/User';
6 | import { apiClient } from 'utils/api';
7 | import { AsyncThunkConfig } from 'store/thunks/config';
8 | import { makeRejectValue } from 'store/thunks/utils';
9 |
10 | export type SignUpRequest = {
11 | email: string;
12 | password: string;
13 | password_confirmation: string;
14 | };
15 |
16 | export type SignUpResponse = {
17 | user: User;
18 | };
19 |
20 | export const createUser = createAsyncThunk<
21 | SignUpResponse,
22 | SignUpRequest,
23 | AsyncThunkConfig
24 | >('auth/createUser', async (payload, thunkApi) => {
25 | try {
26 | await apiClient({ apiRoute: false }).get(GET_CSRF_TOKEN_PATH);
27 | const response = await apiClient().post(
28 | SIGNUP_PATH,
29 | { name: payload.email, ...payload },
30 | { validateStatus: (status) => status === 201 } // `201`以外 error
31 | );
32 | return response?.data as SignUpResponse;
33 | } catch (error) {
34 | // `Slice`の`extraReducers`の`rejected`を呼び出す
35 | return thunkApi.rejectWithValue(makeRejectValue(error));
36 | }
37 | });
38 |
--------------------------------------------------------------------------------
/backend/app/Models/TaskBoard.php:
--------------------------------------------------------------------------------
1 | 'array',
27 | 'card_index_map' => 'array',
28 | ];
29 |
30 | protected static function booted()
31 | {
32 | static::creating(function (self $taskBoard) {
33 | $taskBoard->id = (string) Str::uuid();
34 | });
35 | }
36 |
37 | public function user()
38 | {
39 | return $this->belongsTo(User::class);
40 | }
41 |
42 | public function taskLists()
43 | {
44 | return $this->hasMany(TaskList::class);
45 | }
46 |
47 | public function taskCards()
48 | {
49 | return $this->hasManyThrough(TaskCard::class, TaskList::class);
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/backend/public/web.config:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/frontend/src/store/index.ts:
--------------------------------------------------------------------------------
1 | import { AnyAction, combineReducers, configureStore } from '@reduxjs/toolkit';
2 |
3 | import { appSlice, authSlice, taskBoardSlice } from './slices';
4 |
5 | const combinedReducer = combineReducers({
6 | app: appSlice.reducer,
7 | auth: authSlice.reducer,
8 | boards: taskBoardSlice.reducer,
9 | });
10 |
11 | export type RootState = ReturnType;
12 |
13 | export const rootReducer = (
14 | state: RootState | undefined,
15 | action: AnyAction
16 | ) => {
17 | const actionsWithReset = [
18 | 'auth/signOut',
19 | 'auth/signOutFromAPI/fulfilled',
20 | 'auth/deleteAccount/fulfilled',
21 | ];
22 |
23 | if (actionsWithReset.includes(action.type)) {
24 | state = undefined;
25 | }
26 | return combinedReducer(state, action);
27 | };
28 |
29 | export const store = configureStore({
30 | reducer: rootReducer,
31 | middleware: (getDefaultMiddleware) =>
32 | getDefaultMiddleware({
33 | /**
34 | * If your state or actions are very large,
35 | * the SerializableStateInvariantMiddleware,
36 | * that causes a slowdown in dev, can be disabled
37 | */
38 | serializableCheck: false,
39 | }),
40 | });
41 |
42 | export type AppDispatch = typeof store.dispatch;
43 |
44 | export default store;
45 |
--------------------------------------------------------------------------------
/backend/database/migrations/2021_03_14_180349_create_task_cards_table.php:
--------------------------------------------------------------------------------
1 | comment('Minimum unit of a task');
18 |
19 | $table->uuid('id')->primary();
20 | $table
21 | ->foreignUuid('user_id')
22 | ->comment('User ID')
23 | ->constrained()
24 | ->cascadeOnUpdate()
25 | ->cascadeOnDelete();
26 | $table->string('title', 255);
27 | $table->string('content', 2000)->nullable();
28 | $table->dateTime('deadline')->nullable();
29 | $table->boolean('done')->default(false);
30 | $table->timestamps();
31 | });
32 | }
33 |
34 | /**
35 | * Reverse the migrations.
36 | *
37 | * @return void
38 | */
39 | public function down()
40 | {
41 | Schema::dropIfExists('task_cards');
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/backend/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 | ./tests/Unit
10 |
11 |
12 | ./tests/Feature
13 |
14 |
15 |
16 |
17 | ./app
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/cards/destroyTaskCard.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { TaskCard } from 'models';
4 | import { apiClient, makePath } from 'utils/api';
5 | import { AsyncThunkConfig } from 'store/thunks/config';
6 | import { makeRejectValue } from 'store/thunks/utils';
7 |
8 | export type DestroyTaskCardResponse = {
9 | data: TaskCard;
10 | };
11 |
12 | export type DestroyTaskCardArg = Pick;
13 |
14 | export const destroyTaskCard = createAsyncThunk<
15 | Pick & DestroyTaskCardResponse,
16 | DestroyTaskCardArg,
17 | AsyncThunkConfig
18 | >('cards/destroyTaskCard', async (payload, thunkApi) => {
19 | const { setFlash } = await import('store/slices/authSlice');
20 |
21 | const { id, listId } = payload;
22 | const path = makePath(['task-lists', listId], ['task-cards', id]);
23 |
24 | try {
25 | const response = await apiClient().delete(path);
26 |
27 | thunkApi.dispatch(
28 | setFlash({
29 | type: 'warning',
30 | message: `${response.data.data.title} は削除されました`,
31 | })
32 | );
33 |
34 | return { ...response?.data, boardId: payload.boardId };
35 | } catch (error) {
36 | return thunkApi.rejectWithValue(makeRejectValue(error));
37 | }
38 | });
39 |
--------------------------------------------------------------------------------
/backend/database/factories/UserFactory.php:
--------------------------------------------------------------------------------
1 | $this->faker->name,
28 | 'email' => $this->faker->unique()->safeEmail,
29 | 'email_verified_at' => now(),
30 | 'password' => Hash::make('password'),
31 | 'remember_token' => Str::random(10),
32 | ];
33 | }
34 |
35 | /**
36 | * Indicate that the model's email address should be unverified.
37 | *
38 | * @return \Illuminate\Database\Eloquent\Factories\Factory
39 | */
40 | public function unverified()
41 | {
42 | return $this->state(function (array $attributes) {
43 | return [
44 | 'email_verified_at' => null,
45 | ];
46 | });
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/frontend/src/utils/auth.ts:
--------------------------------------------------------------------------------
1 | import store from 'store';
2 | import { GUEST_EMAIL } from 'config/app';
3 | import { fetchAuthUser } from 'store/thunks/auth';
4 |
5 | export const isReady = () => store.getState().auth.signedIn !== undefined;
6 |
7 | export const isSignedIn = () => store.getState().auth.signedIn;
8 |
9 | export const isAfterRegistration = () =>
10 | store.getState().auth.afterRegistration;
11 |
12 | export const isVerified = () => !!store.getState().auth.user?.emailVerifiedAt;
13 |
14 | export const isGuest = () => store.getState().auth.user?.email === GUEST_EMAIL;
15 |
16 | // store`signedIn`更新時に実行すること -> `useEffect`
17 | export const initializeAuthState = () => {
18 | const localStorageKeys = {
19 | SIGNED_IN: 'SIGNED_IN',
20 | } as const;
21 | const localStorageValues = {
22 | TRUE: 'TRUE',
23 | FALSE: 'FALSE',
24 | } as const;
25 | const { SIGNED_IN } = localStorageKeys;
26 | const { TRUE, FALSE } = localStorageValues;
27 |
28 | // 初回 (session開始時) のみサーバーの認証状態を問い合わせ
29 | isSignedIn() === undefined && store.dispatch(fetchAuthUser());
30 |
31 | // (case null:) 更新した`store`の値で`localStorage`も更新
32 | const signedIn = isSignedIn() ? TRUE : FALSE;
33 | localStorage.setItem(SIGNED_IN, signedIn);
34 |
35 | // ブラウザでのログイン状態を示すが、サーバー側で認証切れの可能性あり
36 | // -> その確認は`apiClient.interceptors`で行う
37 | };
38 |
--------------------------------------------------------------------------------
/backend/app/Http/Requests/StoreTaskCardRequest.php:
--------------------------------------------------------------------------------
1 |
24 | */
25 | public function validationData()
26 | {
27 | $requests = [];
28 |
29 | foreach ($this->all() as $key => $value) {
30 | $requests[Str::snake($key)] = $value;
31 | }
32 |
33 | return $requests;
34 | }
35 |
36 | /**
37 | * Get the validation rules that apply to the request.
38 | *
39 | * @return array
40 | */
41 | public function rules()
42 | {
43 | return [
44 | 'title' => 'required|string|min:1|max:255',
45 | 'content' => 'nullable|string|min:1|max:2000',
46 | 'deadline' => 'date',
47 | 'done' => 'boolean',
48 | ];
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/backend/database/seeders/UserSeeder.php:
--------------------------------------------------------------------------------
1 | Once the configuration has been cached, the .env file will not be loaded.
23 | // > https://laravel.com/docs/9.x/configuration#configuration-caching
24 | //
25 | //`env()` returns `null`, after running `php artisan config:cache`.
26 | // Use `config()` instead. To remove the cache, run `php artisan config:clear`
27 | // (The cached config is stored in `bootstrap/cache/config.php`)
28 |
29 | self::$guestUser = User::factory()
30 | ->has(TaskBoard::factory()->count(40))
31 | ->create([
32 | 'name' => config('fortify.guest.name'),
33 | 'email' => config('fortify.guest.email'),
34 | 'password' => Hash::make(config('fortify.guest.password')),
35 | ]);
36 |
37 | self::$anotherUser = User::factory()->create();
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/backend/app/Http/Requests/UpdateTaskBoardRequest.php:
--------------------------------------------------------------------------------
1 |
24 | */
25 | public function validationData()
26 | {
27 | $requests = [];
28 |
29 | foreach ($this->all() as $key => $value) {
30 | $requests[Str::snake($key)] = $value;
31 | }
32 |
33 | return $requests;
34 | }
35 |
36 | /**
37 | * Get the validation rules that apply to the request.
38 | *
39 | * @return array
40 | */
41 | public function rules()
42 | {
43 | return [
44 | 'title' => 'string|min:1|max:255',
45 | 'description' => 'nullable|string|max:2000',
46 | 'list_index_map' => 'nullable|array|min:1',
47 | 'card_index_map' => 'nullable|array|min:1',
48 | ];
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/config/AsyncThunkConfig.ts:
--------------------------------------------------------------------------------
1 | import { AppDispatch, RootState } from 'store';
2 | import { HttpException, InvalidRequest } from 'utils/api/errors';
3 |
4 | export type RejectValue = {
5 | error: HttpException | InvalidRequest | { message: string };
6 | };
7 |
8 | /**
9 | * @see https://redux-toolkit.js.org/usage/usage-with-typescript#createasyncthunk
10 | */
11 | export type AsyncThunkConfig = {
12 | /** return type for `thunkApi.getState` */
13 | state: RootState;
14 | /** type for `thunkApi.dispatch` */
15 | dispatch: AppDispatch;
16 | /** type of the `extra` argument for the thunk middleware, which will be passed in as `thunkApi.extra` */
17 | extra?: unknown;
18 | /** type to be passed into `rejectWithValue`'s first argument that will end up on `rejectedAction.payload` */
19 | rejectValue: RejectValue;
20 | /** return type of the `serializeError` option callback */
21 | serializedErrorType?: unknown;
22 | /** type to be returned from the `getPendingMeta` option callback & merged into `pendingAction.meta` */
23 | pendingMeta?: unknown;
24 | /** type to be passed into the second argument of `fulfillWithValue` to finally be merged into `fulfilledAction.meta` */
25 | fulfilledMeta?: unknown;
26 | /** type to be passed into the second argument of `rejectWithValue` to finally be merged into `rejectedAction.meta` */
27 | rejectedMeta?: unknown;
28 | };
29 |
--------------------------------------------------------------------------------
/frontend/src/templates/Markdown.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 |
3 | import MarkdownToJsx, { MarkdownToJSX } from 'markdown-to-jsx';
4 | import { Typography } from '@material-ui/core';
5 |
6 | const options: MarkdownToJSX.Options = {
7 | slugify: (str) => str, // 自動生成されるid属性を日本語で利用
8 | overrides: {
9 | h1: {
10 | component: (props) => (
11 |
12 | ),
13 | },
14 | h2: {
15 | component: (props) => (
16 |
17 | ),
18 | },
19 | h3: {
20 | component: (props) => (
21 |
22 | ),
23 | },
24 | h4: {
25 | component: (props) => (
26 |
27 | ),
28 | },
29 | p: { component: (props) => },
30 | ol: { props: { style: { paddingInlineStart: '1.6rem' } } },
31 | li: {
32 | component: (props) => ,
33 | },
34 | },
35 | };
36 |
37 | const Markdown: React.FC = ({ children }) => {
38 | return (
39 |
40 | {children as string & ReactNode}
41 |
42 | );
43 | };
44 |
45 | export default Markdown;
46 |
--------------------------------------------------------------------------------
/backend/database/migrations/2021_06_17_161616_create_task_lists_table.php:
--------------------------------------------------------------------------------
1 | comment('Group of task cards');
18 |
19 | $table->uuid('id')->primary();
20 | $table
21 | ->foreignUuid('user_id')
22 | ->comment('User ID')
23 | ->constrained()
24 | ->cascadeOnUpdate()
25 | ->cascadeOnDelete();
26 | $table
27 | ->foreignUuid('task_board_id')
28 | ->comment('Task Board ID')
29 | ->constrained()
30 | ->cascadeOnUpdate()
31 | ->cascadeOnDelete();
32 | $table->string('title', 255);
33 | $table->string('description', 2000)->nullable();
34 | $table->timestamps();
35 | });
36 | }
37 |
38 | /**
39 | * Reverse the migrations.
40 | *
41 | * @return void
42 | */
43 | public function down()
44 | {
45 | Schema::dropIfExists('task_lists');
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/backend/app/Http/Controllers/VerifyEmailController.php:
--------------------------------------------------------------------------------
1 | user();
31 |
32 | // If already authenticated
33 | if ($user->hasVerifiedEmail()) {
34 | abort(403);
35 | }
36 |
37 | if ($user->markEmailAsVerified()) {
38 | event(new Verified($user));
39 | }
40 |
41 | return app(VerifyEmailResponse::class);
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/frontend/src/config/api.ts:
--------------------------------------------------------------------------------
1 | export const API_HOST = process.env.REACT_APP_API_HOST || 'http://localhost';
2 | export const API_VERSION = process.env.REACT_APP_API_VERSION || 'v1';
3 | export const API_ROUTE = API_HOST + '/' + API_VERSION;
4 |
5 | // paths
6 | export const GET_CSRF_TOKEN_PATH =
7 | process.env.REACT_APP_GET_CSRF_TOKEN_PATH || '/sanctum/csrf-cookie';
8 |
9 | export const SIGNUP_PATH = process.env.REACT_APP_SIGNUP_PATH || '/register';
10 |
11 | export const VERIFICATION_NOTIFICATION_PATH =
12 | process.env.REACT_APP_VERIFICATION_NOTIFICATION_PATH ||
13 | '/email/verification-notification';
14 |
15 | export const SIGNIN_PATH = process.env.REACT_APP_SIGNIN_PATH || '/login';
16 |
17 | export const USER_INFO_PATH =
18 | process.env.REACT_APP_USER_INFO_PATH || '/user/profile-information';
19 |
20 | export const UPDATE_PASSWORD_PATH =
21 | process.env.REACT_APP_UPDATE_PASSWORD_PATH || '/user/password';
22 |
23 | export const SIGNOUT_PATH = process.env.REACT_APP_SIGNOUT_PATH || '/logout';
24 |
25 | export const FORGOT_PASSWORD_PATH =
26 | process.env.REACT_APP_FORGOT_PASSWORD_PATH || '/forgot-password';
27 |
28 | export const RESET_PASSWORD_PATH =
29 | process.env.REACT_APP_RESET_PASSWORD_PATH || '/reset-password';
30 |
31 | export const paths = {
32 | GET_CSRF_TOKEN_PATH,
33 | SIGNUP_PATH,
34 | VERIFICATION_NOTIFICATION_PATH,
35 | SIGNIN_PATH,
36 | USER_INFO_PATH,
37 | UPDATE_PASSWORD_PATH,
38 | SIGNOUT_PATH,
39 | FORGOT_PASSWORD_PATH,
40 | RESET_PASSWORD_PATH,
41 | } as const;
42 |
--------------------------------------------------------------------------------
/terraform/route53.tf:
--------------------------------------------------------------------------------
1 | locals {
2 | root_domain = var.root_domain
3 | }
4 |
5 | ################################################################################
6 | # Hosted Zone (Read only)
7 | ################################################################################
8 | # https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/migrate-dns-domain-in-use.html
9 | # https://www.terraform.io/docs/language/data-sources/index.html
10 | # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/route53_zone
11 |
12 | data "aws_route53_zone" "root" {
13 | name = local.root_domain.name
14 | }
15 |
16 | ################################################################################
17 | # DNS records
18 | ################################################################################
19 | # https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resource-record-sets-values-basic.html#rrsets-values-basic-ttl
20 | # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record
21 |
22 | resource "aws_route53_record" "root" {
23 | zone_id = data.aws_route53_zone.root.zone_id
24 | name = data.aws_route53_zone.root.name
25 | type = "A"
26 | ttl = "300"
27 | records = local.root_domain.records.a
28 | }
29 |
30 | resource "aws_route53_record" "www" {
31 | zone_id = data.aws_route53_zone.root.zone_id
32 | name = "www.${data.aws_route53_zone.root.name}"
33 | type = "CNAME"
34 | ttl = "300"
35 | records = local.root_domain.records.cname
36 | }
37 |
--------------------------------------------------------------------------------
/frontend/src/mocks/controllers/taskCardController.ts:
--------------------------------------------------------------------------------
1 | import { RestRequest } from 'msw';
2 |
3 | import { TaskCard } from 'models';
4 | import {
5 | CreateTaskCardRequest,
6 | UpdateTaskCardRequest,
7 | } from 'store/thunks/cards';
8 | import { db, TaskCardDocument } from 'mocks/models';
9 |
10 | export const store = (req: RestRequest) => {
11 | const parent = db.where('taskLists', 'id', req.params.listId)[0];
12 | const newCard = db.create('taskCards', {
13 | ...({} as TaskCardDocument),
14 | listId: req.params.listId,
15 | ...req.body,
16 | });
17 |
18 | const response: TaskCard = { ...newCard, boardId: parent.boardId };
19 |
20 | return response;
21 | };
22 |
23 | export const update = (req: RestRequest) => {
24 | const card = db.where('taskCards', 'id', req.params.cardId)[0];
25 |
26 | if (!card) return;
27 |
28 | const updated = db.update('taskCards', { ...card, ...req.body });
29 |
30 | const parent = db.where('taskLists', 'id', req.params.listId)[0];
31 | const boardId = parent.boardId;
32 | const response: TaskCard = { ...updated, boardId };
33 |
34 | return response;
35 | };
36 |
37 | export const destroy = (req: RestRequest) => {
38 | const deleted = db.remove('taskCards', req.params.cardId);
39 |
40 | if (!deleted) return;
41 |
42 | const parent = db.where('taskLists', 'id', req.params.listId)[0];
43 | const boardId = parent.boardId;
44 | const response: TaskCard = { ...deleted, boardId };
45 |
46 | return response;
47 | };
48 |
--------------------------------------------------------------------------------
/terraform/terraform.tfvars.example:
--------------------------------------------------------------------------------
1 | # Rename this file `terraform.tfvar`. See the following links to know more info
2 | # https://www.terraform.io/docs/language/values/variables.html#assigning-values-to-root-module-variables
3 | # https://learn.hashicorp.com/tutorials/terraform/sensitive-variables?in=terraform/configuration-language
4 |
5 | ################################################################################
6 | # Main
7 | ################################################################################
8 | project = "App"
9 | stage = "dev"
10 | aws_profile = "default"
11 | aws_region = "ap-northeast-1"
12 |
13 | ################################################################################
14 | # Route53
15 | ################################################################################
16 | root_domain = {
17 | name = "example.com"
18 | records = {
19 | a = ["76.76.21.21"]
20 | cname = ["cname.vercel-dns.com"]
21 | }
22 | }
23 |
24 | ################################################################################
25 | # VPC
26 | ################################################################################
27 | vpc_cidr_block = "10.0.0.0/16"
28 | vpc_private_subnets = [
29 | "10.0.1.0/24",
30 | "10.0.2.0/24",
31 | "10.0.3.0/24",
32 | "10.0.4.0/24",
33 | "10.0.5.0/24",
34 | "10.0.6.0/24"
35 | ]
36 |
37 | ################################################################################
38 | # RDS
39 | ################################################################################
40 | db_username = "admin"
41 |
--------------------------------------------------------------------------------
/frontend/src/layouts/Footer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Link as RouterLink } from 'react-router-dom';
4 | import { makeStyles, createStyles, Theme } from '@material-ui/core/styles';
5 | import { Container, Grid, Link, Box, Typography } from '@material-ui/core';
6 |
7 | import { APP_NAME } from 'config/app';
8 |
9 | const useStyles = makeStyles((theme: Theme) =>
10 | createStyles({
11 | footer: {
12 | marginTop: 'auto',
13 | paddingTop: theme.spacing(4),
14 | paddingBottom: theme.spacing(1),
15 | backgroundColor: theme.palette.common.black,
16 | color: theme.palette.common.white,
17 | },
18 | })
19 | );
20 |
21 | const Copyright = () => (
22 |
23 | © {APP_NAME} {new Date().getFullYear()}
24 |
25 | );
26 |
27 | const Footer: React.FC = () => {
28 | const classes = useStyles();
29 |
30 | return (
31 |
32 |
33 |
34 |
35 | Terms
36 |
37 |
38 |
39 | Privacy
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | );
48 | };
49 |
50 | export default Footer;
51 |
--------------------------------------------------------------------------------
/frontend/src/store/thunks/cards/updateTaskCard.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { TaskCard } from 'models';
4 | import { apiClient, makePath } from 'utils/api';
5 | import { AsyncThunkConfig } from 'store/thunks/config';
6 | import { makeRejectValue } from 'store/thunks/utils';
7 |
8 | export type UpdateTaskCardResponse = {
9 | data: TaskCard;
10 | };
11 |
12 | export type UpdateTaskCardRequest = Partial<
13 | Pick
14 | >;
15 |
16 | export type UpdateTaskCardArg = Pick &
17 | UpdateTaskCardRequest;
18 |
19 | export const updateTaskCard = createAsyncThunk<
20 | Pick & UpdateTaskCardResponse,
21 | UpdateTaskCardArg,
22 | AsyncThunkConfig
23 | >('cards/updateTaskCard', async (payload, thunkApi) => {
24 | const { id, boardId, listId, ...requestBody } = payload;
25 | const path = makePath(['task-lists', listId], ['task-cards', id]);
26 | /**
27 | * - `Data`型はタイムゾーンを反映させた値としてAPIリクエストを送る
28 | * - Laravel側ではこれを`DateTime`型にキャストして扱い、またDBに保存する
29 | *
30 | * @see https://laravel.com/docs/8.x/eloquent-mutators#date-casting-and-timezones
31 | */
32 | const request = !requestBody.deadline
33 | ? requestBody
34 | : { ...requestBody, deadline: requestBody.deadline.toLocaleString() };
35 |
36 | try {
37 | const response = await apiClient().patch(path, request);
38 | return { ...response?.data, boardId: payload.boardId };
39 | } catch (error) {
40 | return thunkApi.rejectWithValue(makeRejectValue(error));
41 | }
42 | });
43 |
--------------------------------------------------------------------------------
/backend/.env.example:
--------------------------------------------------------------------------------
1 | # Rename this file `.env`.
2 |
3 | WWWUSER=1000
4 | WWWGROUP=1000
5 | PROJECT_DIR=/var/www/html
6 | BACKEND_DIR=backend
7 |
8 | APP_NAME=Laravel
9 | APP_ENV=local
10 | APP_KEY= # artisan key:generate
11 | APP_DEBUG=true
12 | APP_URL=http://localhost
13 |
14 | API_ROUTE_PREFIX='api/'
15 | API_VERSION='v1'
16 |
17 | LOG_CHANNEL=stack
18 | LOG_LEVEL=debug
19 |
20 | DB_CONNECTION=mysql
21 | DB_HOST=mysql
22 | DB_PORT=3306
23 | DB_DATABASE=backend
24 | DB_USERNAME=root
25 | DB_PASSWORD=
26 |
27 | BROADCAST_DRIVER=log
28 | CACHE_DRIVER=redis
29 | QUEUE_CONNECTION=redis
30 | SESSION_DRIVER=redis
31 | SESSION_LIFETIME=120 # ログイン時間(分)
32 | SESSION_DOMAIN=localhost # domain属性
33 | SESSION_SECURE_COOKIE=true # secure属性
34 |
35 | SANCTUM_STATEFUL_DOMAINS=localhost:3000 # Frontend URI
36 | SPA_URL=http://localhost:3000 # Frontend URI with protocol
37 |
38 | MEMCACHED_HOST=memcached
39 |
40 | REDIS_HOST=redis
41 | REDIS_PASSWORD=null
42 | REDIS_PORT=6379
43 |
44 | MAIL_MAILER=smtp
45 | MAIL_HOST=mailhog
46 | MAIL_PORT=1025
47 | MAIL_USERNAME=null
48 | MAIL_PASSWORD=null
49 | MAIL_ENCRYPTION=null
50 | MAIL_FROM_ADDRESS=no-reply@example.com
51 | MAIL_FROM_NAME="${APP_NAME}"
52 |
53 | AWS_ACCESS_KEY_ID=
54 | AWS_SECRET_ACCESS_KEY=
55 | AWS_DEFAULT_REGION=us-east-1
56 | AWS_BUCKET=
57 |
58 | PUSHER_APP_ID=
59 | PUSHER_APP_KEY=
60 | PUSHER_APP_SECRET=
61 | PUSHER_APP_CLUSTER=mt1
62 |
63 | MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
64 | MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
65 |
66 | SCOUT_DRIVER=meilisearch
67 | MEILISEARCH_HOST=http://meilisearch:7700
68 |
69 | GUEST_NAME='ゲストユーザー'
70 | GUEST_EMAIL=test@example.com
71 | GUEST_PASSWORD=password
72 |
--------------------------------------------------------------------------------
/backend/database/seeders/TaskBoardSeeder.php:
--------------------------------------------------------------------------------
1 | for(UserSeeder::$guestUser);
20 | $guestListFactory = TaskList::factory()->for(UserSeeder::$guestUser);
21 |
22 | TaskBoard::factory()
23 | ->for(UserSeeder::$guestUser)
24 | ->has(
25 | $guestListFactory
26 | ->state(['title' => 'List 1'])
27 | ->has($guestCardFactory->state(['title' => 'Card 1']))
28 | ->has($guestCardFactory->state(['title' => 'Card 2']))
29 | ->has($guestCardFactory->state(['title' => 'Card 3'])),
30 | )
31 | ->has(
32 | $guestListFactory
33 | ->state(['title' => 'List 2'])
34 | ->has($guestCardFactory->state(['title' => 'Card 4']))
35 | ->has($guestCardFactory->state(['title' => 'Card 5']))
36 | ->has($guestCardFactory->state(['title' => 'Card 6'])),
37 | )
38 | ->create([
39 | 'title' => 'Board 1',
40 | 'description' => 'Board description',
41 | ]);
42 |
43 | TaskBoard::factory()
44 | ->for(UserSeeder::$anotherUser)
45 | ->create();
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/frontend/src/components/layouts/Header/AccountMenuList.tsx:
--------------------------------------------------------------------------------
1 | import { useHistory } from 'react-router-dom';
2 | import { makeStyles, createStyles } from '@material-ui/core/styles';
3 | import { List, ListItem, ListItemIcon, ListItemText } from '@material-ui/core';
4 | import {
5 | AccountCircle as AccountCircleIcon,
6 | ExitToApp as ExitToAppIcon,
7 | } from '@material-ui/icons';
8 |
9 | import { signOutFromAPI } from 'store/thunks/auth';
10 | import { useAppDispatch, useAppSelector } from 'utils/hooks';
11 |
12 | const useStyles = makeStyles(() =>
13 | createStyles({
14 | root: {
15 | maxWidth: '300px',
16 | overflowWrap: 'break-word',
17 | },
18 | })
19 | );
20 |
21 | const AccountMenuList = () => {
22 | const classes = useStyles();
23 | const history = useHistory();
24 | const username = useAppSelector((state) => state.auth.user?.name);
25 | const dispatch = useAppDispatch();
26 |
27 | const handleClick = (path: string) => () => history.push(path);
28 |
29 | const handleSignOut = () => {
30 | dispatch(signOutFromAPI());
31 | };
32 |
33 | return (
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | );
49 | };
50 |
51 | export default AccountMenuList;
52 |
--------------------------------------------------------------------------------
/backend/app/Http/Resources/TaskCardResource.php:
--------------------------------------------------------------------------------
1 | resource = $resource;
22 | }
23 |
24 | /**
25 | * Transform the resource into an array.
26 | *
27 | * @param \Illuminate\Http\Request $request
28 | * @return array
29 | */
30 | public function toArray($request)
31 | {
32 | // Undefined properties can be accessed by `__get()`.
33 | // (Illuminate\Http\Resources\DelegatesToResource::__get)
34 | // But it's not type-safe. So access via a typed variable.
35 | // e.g. `$this->resource->id` instead of `this->id`
36 | $taskCard = $this->resource;
37 |
38 | return [
39 | 'id' => $taskCard->id,
40 | 'userId' => $taskCard->user_id,
41 | 'listId' => $taskCard->task_list_id,
42 | 'title' => $taskCard->title,
43 | 'content' => $taskCard->content,
44 | 'deadline' => $taskCard->deadline,
45 | 'done' => $taskCard->done,
46 | 'createdAt' => $taskCard->created_at,
47 | 'updatedAt' => $taskCard->updated_at,
48 | ];
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/frontend/src/layouts/MarkdownWithToc.tsx:
--------------------------------------------------------------------------------
1 | import { makeStyles, createStyles, Theme } from '@material-ui/core/styles';
2 | import { Container, Grid, Link } from '@material-ui/core';
3 |
4 | import { Markdown } from 'templates';
5 |
6 | const useStyles = makeStyles((theme: Theme) =>
7 | createStyles({
8 | container: {
9 | marginTop: theme.spacing(8),
10 | [theme.breakpoints.down('xs')]: {
11 | marginTop: theme.spacing(6),
12 | },
13 | marginBottom: theme.spacing(4),
14 | },
15 | toc: {
16 | position: 'sticky', // スクロールしても位置を固定
17 | '&': { position: '-webkit-sticky' }, // Safari用
18 | top: theme.spacing(4), //固定位置
19 | // `ul`スタイル修正
20 | marginBlockStart: 0,
21 | paddingInlineStart: 0,
22 | listStyle: 'none',
23 | },
24 | })
25 | );
26 |
27 | type MarkdownWithTocProps = {
28 | articles: string[];
29 | };
30 |
31 | const MarkdownWithToc: React.FC = (props) => {
32 | const { children, articles } = props;
33 | const classes = useStyles();
34 |
35 | return (
36 |
37 |
38 |
39 |
40 | {articles.map((article, id) => (
41 | -
42 | {article}
43 |
44 | ))}
45 |
46 |
47 |
48 | {children}
49 |
50 |
51 |
52 | );
53 | };
54 |
55 | export default MarkdownWithToc;
56 |
--------------------------------------------------------------------------------
/frontend/src/templates/DatetimeInput.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 |
3 | import moment from 'moment';
4 | import {
5 | KeyboardDateTimePicker,
6 | KeyboardDateTimePickerProps,
7 | } from '@material-ui/pickers';
8 | import { MaterialUiPickersDate } from '@material-ui/pickers/typings/date';
9 |
10 | type DatetimeInputProps = {
11 | onChange: (date?: Date) => void;
12 | } & Omit;
13 |
14 | /**
15 | * @see https://material-ui-pickers.dev/demo/datetime-picker#inline-mode
16 | * @see https://material-ui-pickers.dev/api/KeyboardDateTimePicker
17 | */
18 | const DatetimeInput: React.FC = (props) => {
19 | const { value, onChange, ...keyboardDatetimePickerProps } = props;
20 | const [datetime, setDatetime] = useState(
21 | value ? moment(value) : null
22 | );
23 |
24 | // 表示するデータが変更された場合に値を初期化する
25 | useEffect(() => {
26 | setDatetime(value ? moment(value) : null);
27 | }, [value]);
28 |
29 | /** state`datetime`が変更された場合にデータの更新を実行 */
30 | const handleClose = () => {
31 | if (moment(value).unix() === datetime?.unix()) return;
32 | props.onChange(datetime?.toDate());
33 | };
34 |
35 | return (
36 |
49 | );
50 | };
51 |
52 | export default DatetimeInput;
53 |
--------------------------------------------------------------------------------
/frontend/src/components/account/UserStatus.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment } from 'react';
2 |
3 | import moment from 'moment';
4 | import { Box, Button, Grid, Typography } from '@material-ui/core';
5 |
6 | import { sendEmailVerificationLink } from 'store/thunks/auth';
7 | import { useAppSelector, useAppDispatch } from 'utils/hooks';
8 | import { isVerified } from 'utils/auth';
9 | import { AlertMessage } from 'templates';
10 |
11 | const UserStatus = () => {
12 | const createdAt = useAppSelector((state) => state.auth.user?.createdAt);
13 | const dispatch = useAppDispatch();
14 |
15 | const handleClick = () => {
16 | dispatch(sendEmailVerificationLink());
17 | };
18 |
19 | const showEmailVerificationState = () =>
20 | isVerified() ? (
21 |
22 | ) : (
23 |
24 |
25 | {`メール認証が必要です。`}
26 |
27 |
28 | {`登録から24時間以内に認証を完了させなかった場合、一定時間経過後に登録が抹消されます。`}
29 |
30 |
31 |
32 | {`登録日時 ${moment(createdAt).format('YYYY/MM/DD HH:mm')}`}
33 |
34 |
35 |
36 |
39 |
40 |
41 | );
42 |
43 | return (
44 |
45 |
46 | {showEmailVerificationState()}
47 |
48 |
49 | );
50 | };
51 |
52 | export default UserStatus;
53 |
--------------------------------------------------------------------------------
/frontend/src/pages/error/NotFound.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | import { useHistory } from 'react-router-dom';
4 | import { makeStyles, createStyles, Theme } from '@material-ui/core/styles';
5 | import { Container, Typography, Button } from '@material-ui/core';
6 |
7 | import { useAppDispatch } from 'utils/hooks';
8 | import { releaseError404 } from 'store/slices/appSlice';
9 | import { BaseLayout } from 'layouts';
10 |
11 | const useStyles = makeStyles((theme: Theme) =>
12 | createStyles({
13 | container: {
14 | marginTop: theme.spacing(7),
15 | marginBottom: theme.spacing(5),
16 | },
17 | })
18 | );
19 |
20 | const NotFound = () => {
21 | const classes = useStyles();
22 | const history = useHistory();
23 | const dispatch = useAppDispatch();
24 |
25 | useEffect(() => {
26 | return function cleanup() {
27 | dispatch(releaseError404());
28 | };
29 | }, [dispatch, history.location]);
30 |
31 | return (
32 |
33 |
34 |
35 | 404 Page Not Found
36 |
37 |
38 | ページが見つかりませんでした。
39 |
40 |
41 | お探しのページは移動または削除された可能性があります。
42 |
43 |
50 |
51 |
52 | );
53 | };
54 |
55 | export default NotFound;
56 |
--------------------------------------------------------------------------------
/frontend/src/__tests__/store/auth/thunks/forgotPassoword.test.ts:
--------------------------------------------------------------------------------
1 | import { GUEST_EMAIL } from 'config/app';
2 | import { forgotPassword, ForgotPasswordRequest } from 'store/thunks/auth';
3 | import { isInvalidRequest } from 'utils/api/errors';
4 | import { initializeStore, store } from 'mocks/store';
5 | import { getFlashState, isLoading, isSignedIn } from 'mocks/utils/store/auth';
6 |
7 | describe('Thunk for a forgot password', () => {
8 | beforeEach(() => {
9 | initializeStore();
10 | });
11 |
12 | describe('Rejected', () => {
13 | const request: ForgotPasswordRequest = {
14 | email: GUEST_EMAIL + 'a',
15 | };
16 |
17 | it('should receive an error if the email unmatchs', async () => {
18 | expect(isSignedIn(store)).toBeUndefined();
19 | const response = await store.dispatch(forgotPassword(request));
20 | expect(forgotPassword.rejected.match(response)).toBe(true);
21 | if (!forgotPassword.rejected.match(response)) return; // 以下`rejected`
22 |
23 | expect(isLoading(store)).toBe(false);
24 | const error = response.payload?.error;
25 | expect(isInvalidRequest(error) && error.response.status).toBe(422);
26 | });
27 | });
28 |
29 | describe('Fulfilled', () => {
30 | const request: ForgotPasswordRequest = {
31 | email: GUEST_EMAIL,
32 | };
33 |
34 | it('should send an email if an requested email exists', async () => {
35 | expect(isSignedIn(store)).toBeUndefined();
36 | await store.dispatch(forgotPassword(request)); // dispatch
37 |
38 | expect(isLoading(store)).toBe(false);
39 | expect(getFlashState(store).slice(-1)[0]).toEqual({
40 | type: 'success',
41 | message: 'パスワード再設定用のメールを送信しました',
42 | });
43 | });
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/backend/app/Providers/TelescopeServiceProvider.php:
--------------------------------------------------------------------------------
1 | hideSensitiveRequestDetails();
22 |
23 | Telescope::filter(function (IncomingEntry $entry) {
24 | return true;
25 | });
26 | }
27 |
28 | /**
29 | * Prevent sensitive request details from being logged by Telescope.
30 | *
31 | * @return void
32 | */
33 | protected function hideSensitiveRequestDetails()
34 | {
35 | if ($this->app->environment('local')) {
36 | return;
37 | }
38 |
39 | Telescope::hideRequestParameters(['_token']);
40 |
41 | Telescope::hideRequestHeaders([
42 | 'cookie',
43 | 'x-csrf-token',
44 | 'x-xsrf-token',
45 | ]);
46 | }
47 |
48 | /**
49 | * Register the Telescope gate.
50 | *
51 | * This gate determines who can access Telescope in non-local environments.
52 | *
53 | * @return void
54 | */
55 | protected function gate()
56 | {
57 | Gate::define('viewTelescope', function ($user) {
58 | return in_array(
59 | $user->email,
60 | config('telescope.user.emails'),
61 | true,
62 | );
63 | });
64 | }
65 | }
66 |
--------------------------------------------------------------------------------