├── src ├── client │ ├── index.ts │ ├── signUp │ │ ├── selectors.ts │ │ ├── index.ts │ │ ├── operations.ts │ │ ├── actions.ts │ │ ├── reducer.ts │ │ └── types.ts │ ├── pages │ │ ├── Timeline.tsx │ │ ├── index.ts │ │ ├── SignUp.tsx │ │ └── SignIn.tsx │ ├── index.html │ ├── app │ │ ├── index.ts │ │ ├── actions.ts │ │ ├── types.ts │ │ └── reducer.ts │ ├── signIn │ │ ├── index.ts │ │ ├── operations.ts │ │ ├── actions.ts │ │ ├── reducer.ts │ │ └── types.ts │ ├── Root.tsx │ ├── main.tsx │ ├── webpack.config.js │ ├── diContainer.ts │ ├── types.ts │ └── store.ts ├── data-access │ ├── index.ts │ └── MemoryDataAccess.ts ├── utils │ ├── AutoNamedError.ts │ ├── index.ts │ ├── WrapError.ts │ └── WrapError.test.ts ├── usecases │ ├── createRetweet │ │ ├── DataAccessError.ts │ │ ├── index.ts │ │ ├── factory.ts │ │ └── interface.ts │ ├── createTweet │ │ ├── DataAccessError.ts │ │ ├── UserNotExists.ts │ │ ├── InvalidTweetText.ts │ │ ├── index.ts │ │ ├── factory.ts │ │ └── interface.ts │ ├── createUser │ │ ├── DataAccessError.ts │ │ ├── InvalidUserName.ts │ │ ├── UserNameDuplicated.ts │ │ ├── index.ts │ │ ├── interface.ts │ │ └── factory.ts │ ├── getTweets │ │ ├── DataAccessError.ts │ │ ├── index.ts │ │ ├── factory.ts │ │ └── interface.ts │ ├── signInWithPassword │ │ ├── DataAccessError.ts │ │ ├── index.ts │ │ ├── factory.ts │ │ └── interface.ts │ ├── signUpWithPassword │ │ ├── DataAccessError.ts │ │ ├── index.ts │ │ ├── interface.ts │ │ └── factory.ts │ ├── interface.ts │ └── index.ts └── entities │ └── index.ts ├── README.md ├── .prettierrc.json ├── jest.config.js ├── tslint.json ├── LICENSE ├── .gitignore ├── package.json └── tsconfig.json /src/client/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/client/signUp/selectors.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # twitter-like-app-clean-architecture -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /src/data-access/index.ts: -------------------------------------------------------------------------------- 1 | export { default as MemoryDataAccess } from './MemoryDataAccess'; 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node' 4 | }; 5 | -------------------------------------------------------------------------------- /src/utils/AutoNamedError.ts: -------------------------------------------------------------------------------- 1 | export default class AutoNamedError extends Error { 2 | public name = this.constructor.name; 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AutoNamedError } from './AutoNamedError'; 2 | export { default as WrapError } from './WrapError'; 3 | -------------------------------------------------------------------------------- /src/usecases/createRetweet/DataAccessError.ts: -------------------------------------------------------------------------------- 1 | import { WrapError } from '@pirosikick/utils'; 2 | 3 | export default class DataAccessError extends WrapError {} 4 | -------------------------------------------------------------------------------- /src/usecases/createTweet/DataAccessError.ts: -------------------------------------------------------------------------------- 1 | import { WrapError } from '@pirosikick/utils'; 2 | 3 | export default class DataAccessError extends WrapError {} 4 | -------------------------------------------------------------------------------- /src/usecases/createUser/DataAccessError.ts: -------------------------------------------------------------------------------- 1 | import { WrapError } from '@pirosikick/utils'; 2 | 3 | export default class DataAccessError extends WrapError {} 4 | -------------------------------------------------------------------------------- /src/client/pages/Timeline.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | const Timeline: React.FC<{}> = () =>

Timeline

; 4 | 5 | export default Timeline; 6 | -------------------------------------------------------------------------------- /src/usecases/createTweet/UserNotExists.ts: -------------------------------------------------------------------------------- 1 | import { AutoNamedError } from '@pirosikick/utils'; 2 | 3 | export default class UserNotExists extends AutoNamedError {} 4 | -------------------------------------------------------------------------------- /src/usecases/getTweets/DataAccessError.ts: -------------------------------------------------------------------------------- 1 | import { WrapError } from '@pirosikick/utils'; 2 | 3 | export default class GetTweetsDataAccessError extends WrapError {} 4 | -------------------------------------------------------------------------------- /src/usecases/createTweet/InvalidTweetText.ts: -------------------------------------------------------------------------------- 1 | import { AutoNamedError } from '@pirosikick/utils'; 2 | 3 | export default class InvalidTweetText extends AutoNamedError {} 4 | -------------------------------------------------------------------------------- /src/usecases/createUser/InvalidUserName.ts: -------------------------------------------------------------------------------- 1 | import { AutoNamedError } from '@pirosikick/utils'; 2 | 3 | export default class InvalidUserName extends AutoNamedError {} 4 | -------------------------------------------------------------------------------- /src/usecases/createUser/UserNameDuplicated.ts: -------------------------------------------------------------------------------- 1 | import { AutoNamedError } from '@pirosikick/utils'; 2 | 3 | export default class UserNameDuplicated extends AutoNamedError {} 4 | -------------------------------------------------------------------------------- /src/client/pages/index.ts: -------------------------------------------------------------------------------- 1 | export { default as SignIn } from './SignIn'; 2 | export { default as SignUp } from './SignUp'; 3 | export { default as Timeline } from './Timeline'; 4 | -------------------------------------------------------------------------------- /src/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | サンプルアップ 5 | 6 | 7 |
8 | 9 | -------------------------------------------------------------------------------- /src/client/app/index.ts: -------------------------------------------------------------------------------- 1 | import { IAction } from './types'; 2 | import * as actions from './actions'; 3 | import reducer, { IState } from './reducer'; 4 | 5 | export { actions, reducer, IState, IAction }; 6 | -------------------------------------------------------------------------------- /src/usecases/signInWithPassword/DataAccessError.ts: -------------------------------------------------------------------------------- 1 | import { WrapError } from '@pirosikick/utils'; 2 | 3 | export default class SignInWithPasswordDataAccessError extends WrapError { 4 | public name = 'SignInWithPasswordDataAccessError'; 5 | } 6 | -------------------------------------------------------------------------------- /src/usecases/signUpWithPassword/DataAccessError.ts: -------------------------------------------------------------------------------- 1 | import { WrapError } from '@pirosikick/utils'; 2 | 3 | export default class SignUpWithPasswordDataAccessError extends WrapError { 4 | public name = 'SignUpWithPasswordDataAccessError'; 5 | } 6 | -------------------------------------------------------------------------------- /src/client/app/actions.ts: -------------------------------------------------------------------------------- 1 | import { IUser } from '../types'; 2 | import { ActionType, ISetUserAction } from './types'; 3 | 4 | export const setUser = (user: IUser): ISetUserAction => ({ 5 | type: ActionType.SET_USER, 6 | payload: user 7 | }); 8 | -------------------------------------------------------------------------------- /src/client/signIn/index.ts: -------------------------------------------------------------------------------- 1 | import reducer, { IState } from './reducer'; 2 | import { IAction } from './types'; 3 | import * as actions from './actions'; 4 | import * as operations from './operations'; 5 | 6 | export { reducer, actions, operations, IState, IAction }; 7 | -------------------------------------------------------------------------------- /src/client/signUp/index.ts: -------------------------------------------------------------------------------- 1 | import * as actions from './actions'; 2 | import * as operations from './operations'; 3 | import { IAction } from './types'; 4 | import reducer, { IState } from './reducer'; 5 | 6 | export { actions, operations, reducer, IState, IAction }; 7 | -------------------------------------------------------------------------------- /src/usecases/interface.ts: -------------------------------------------------------------------------------- 1 | // ユースケースの型 2 | export type IUseCase = (input: Input) => Promise; 3 | 4 | // ユースケースのFactoryの型 5 | export type IUseCaseFactory> = ( 6 | dataAccess: DataAccess 7 | ) => UseCase; 8 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended", "tslint-config-prettier"], 4 | "jsRules": {}, 5 | "rules": { 6 | "object-literal-sort-keys": false, 7 | "ordered-imports": false 8 | }, 9 | "rulesDirectory": [] 10 | } 11 | -------------------------------------------------------------------------------- /src/client/app/types.ts: -------------------------------------------------------------------------------- 1 | import { IPayloadAction, IUser } from '../types'; 2 | 3 | export enum ActionType { 4 | SET_USER = 'app/app/SET_USER' 5 | } 6 | 7 | export type ISetUserAction = IPayloadAction; 8 | 9 | export type IAction = ISetUserAction; 10 | -------------------------------------------------------------------------------- /src/usecases/createRetweet/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | ICreateRetweetInput, 3 | ICreateRetweetOutput, 4 | ICreateRetweet, 5 | IDataAccess 6 | } from './interface'; 7 | export { default as factory } from './factory'; 8 | export { default as DataAccessError } from './DataAccessError'; 9 | -------------------------------------------------------------------------------- /src/usecases/getTweets/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | IGetTweetsInput, 3 | IGetTweetsOutput, 4 | IGetTweets, 5 | IGetTweetsFactory, 6 | IDataAccess 7 | } from './interface'; 8 | export { default as factory } from './factory'; 9 | export { default as DataAccessError } from './DataAccessError'; 10 | -------------------------------------------------------------------------------- /src/utils/WrapError.ts: -------------------------------------------------------------------------------- 1 | import AutoNamedError from './AutoNamedError'; 2 | 3 | export default class WrapError extends AutoNamedError { 4 | public cause: Error; 5 | 6 | constructor(cause: Error, message: string) { 7 | super(`${message}: ${cause.message}`); 8 | this.cause = cause; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/usecases/signInWithPassword/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | IDataAccess, 3 | ISignInWithPassword, 4 | ISignInWithPasswordFactory, 5 | ISignInWithPasswordInput, 6 | ISignInWithPasswordOutput 7 | } from './interface'; 8 | export { default as DataAccessError } from './DataAccessError'; 9 | export { default as factory } from './factory'; 10 | -------------------------------------------------------------------------------- /src/usecases/signUpWithPassword/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | IDataAccess, 3 | ISignUpWithPassword, 4 | ISignUpWithPasswordInput, 5 | ISignUpWithPasswordOutput, 6 | ISignUpWithPasswordFactory, 7 | ErrorCode 8 | } from './interface'; 9 | export { default as DataAccessError } from './DataAccessError'; 10 | export { default as factory } from './factory'; 11 | -------------------------------------------------------------------------------- /src/usecases/createTweet/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | ICreateTweetInput, 3 | ICreateTweetOutput, 4 | ICreateTweet, 5 | ICreateTweetFactory, 6 | IDataAccess 7 | } from './interface'; 8 | export { default as InvalidTweetText } from './InvalidTweetText'; 9 | export { default as DataAccessError } from './DataAccessError'; 10 | export { default as factory } from './factory'; 11 | -------------------------------------------------------------------------------- /src/usecases/createUser/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | ICreateUser, 3 | ICreateUserFactory, 4 | ICreateUserInput, 5 | ICreateUserOutput, 6 | IDataAccess 7 | } from './interface'; 8 | export { default as factory } from './factory'; 9 | export { default as DataAccessError } from './DataAccessError'; 10 | export { default as InvalidUserName } from './InvalidUserName'; 11 | export { default as UserNameDuplicated } from './UserNameDuplicated'; 12 | -------------------------------------------------------------------------------- /src/utils/WrapError.test.ts: -------------------------------------------------------------------------------- 1 | import WrapError from './WrapError'; 2 | 3 | describe('WrapError', () => { 4 | const cause = new Error('dummy error'); 5 | const message = 'dummy messsage'; 6 | 7 | let err: WrapError; 8 | beforeEach(() => { 9 | err = new WrapError(cause, message); 10 | }); 11 | 12 | test('message is `${message}: ${cause.message}`', () => { 13 | expect(err.message).toBe(`${message}: ${cause.message}`); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/usecases/index.ts: -------------------------------------------------------------------------------- 1 | import * as signUpWithPassword from './signUpWithPassword'; 2 | import * as signInWithPassword from './signInWithPassword'; 3 | import * as createTweet from './createTweet'; 4 | import * as createRetweet from './createRetweet'; 5 | import * as getTweets from './getTweets'; 6 | import * as createUser from './createUser'; 7 | 8 | export { 9 | signUpWithPassword, 10 | signInWithPassword, 11 | createTweet, 12 | createRetweet, 13 | getTweets, 14 | createUser 15 | }; 16 | -------------------------------------------------------------------------------- /src/client/app/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Reducer } from 'redux'; 2 | import { IUser } from '../types'; 3 | import { ActionType, IAction } from './types'; 4 | import * as actions from './actions'; 5 | 6 | export interface IState { 7 | readonly user: IUser | null; 8 | } 9 | const initialState = { user: null }; 10 | 11 | export default (state: IState = initialState, action: IAction) => { 12 | if (action.type === ActionType.SET_USER) { 13 | return { ...state, user: action.payload }; 14 | } 15 | return state; 16 | }; 17 | -------------------------------------------------------------------------------- /src/usecases/getTweets/factory.ts: -------------------------------------------------------------------------------- 1 | import { IGetTweetsFactory } from './interface'; 2 | import DataAccessError from './DataAccessError'; 3 | 4 | const getTweetsFactory: IGetTweetsFactory = dataAccess => { 5 | return async function getTweets(input) { 6 | try { 7 | const tweets = await dataAccess.findTweetsByUserName(input.userName); 8 | return { tweets }; 9 | } catch (cause) { 10 | throw new DataAccessError(cause, 'failed to find tweets by user name'); 11 | } 12 | }; 13 | }; 14 | 15 | export default getTweetsFactory; 16 | -------------------------------------------------------------------------------- /src/usecases/createRetweet/factory.ts: -------------------------------------------------------------------------------- 1 | import { ICreateRetweetFactory } from './interface'; 2 | import DataAccessError from './DataAccessError'; 3 | 4 | const createRetweetFactory: ICreateRetweetFactory = dataAccess => { 5 | return async function createRetweet(input) { 6 | try { 7 | const retweet = await dataAccess.createRetweet( 8 | input.userId, 9 | input.tweetId 10 | ); 11 | return { retweet }; 12 | } catch (cause) { 13 | throw new DataAccessError(cause, 'failed to create retweet'); 14 | } 15 | }; 16 | }; 17 | 18 | export default createRetweetFactory; 19 | -------------------------------------------------------------------------------- /src/usecases/getTweets/interface.ts: -------------------------------------------------------------------------------- 1 | import { ITweet } from '@pirosikick/entities'; 2 | import { IUseCase, IUseCaseFactory } from '../interface'; 3 | 4 | export interface IDataAccess { 5 | findTweetsByUserName(userName: string): Promise; 6 | } 7 | 8 | export interface IGetTweetsInput { 9 | userName: string; 10 | } 11 | 12 | export interface IGetTweetsOutput { 13 | tweets: Array<{ 14 | id: string; 15 | userId: string; 16 | text: string; 17 | createdAt: Date; 18 | }>; 19 | } 20 | 21 | export type IGetTweets = IUseCase; 22 | 23 | export type IGetTweetsFactory = IUseCaseFactory; 24 | -------------------------------------------------------------------------------- /src/usecases/createUser/interface.ts: -------------------------------------------------------------------------------- 1 | import { IUser } from '@pirosikick/entities'; 2 | import { IUseCase, IUseCaseFactory } from '../interface'; 3 | 4 | export interface IDataAccess { 5 | findUserByName(name: string): Promise; 6 | createUser(name: string): Promise; 7 | } 8 | 9 | export interface ICreateUserInput { 10 | userName: string; 11 | } 12 | 13 | export interface ICreateUserOutput { 14 | user: { 15 | id: string; 16 | name: string; 17 | createdAt: Date; 18 | }; 19 | } 20 | 21 | export type ICreateUser = IUseCase; 22 | 23 | export type ICreateUserFactory = IUseCaseFactory; 24 | -------------------------------------------------------------------------------- /src/usecases/signInWithPassword/factory.ts: -------------------------------------------------------------------------------- 1 | import { ISignInWithPasswordFactory } from './interface'; 2 | import DataAccessError from './DataAccessError'; 3 | 4 | const signUpWithPasswordFactory: ISignInWithPasswordFactory = dataAccess => { 5 | return async function signUpWithPassword(input) { 6 | try { 7 | const user = await dataAccess.verifyPassword( 8 | input.userName, 9 | input.password 10 | ); 11 | return user 12 | ? { succeeded: true, user } 13 | : { succeeded: false, user: null }; 14 | } catch (cause) { 15 | throw new DataAccessError(cause, 'failed to verify password'); 16 | } 17 | }; 18 | }; 19 | 20 | export default signUpWithPasswordFactory; 21 | -------------------------------------------------------------------------------- /src/client/Root.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { HashRouter as Router, Route } from 'react-router-dom'; 3 | import { Store } from 'redux'; 4 | import { Provider } from 'react-redux'; 5 | import { IRootState } from './types'; 6 | import * as pages from './pages'; 7 | 8 | interface IProps { 9 | store: Store; 10 | } 11 | 12 | const Root: React.FC = ({ store }) => ( 13 | 14 | 15 | <> 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | 24 | export default Root; 25 | -------------------------------------------------------------------------------- /src/client/main.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import * as usecases from '@pirosikick/usecases'; 4 | import * as app from './app'; 5 | import Root from './Root'; 6 | import diContainer from './diContainer'; 7 | import { IStore } from './types'; 8 | 9 | const createUser = diContainer.resolve( 10 | 'createUser' 11 | ); 12 | const store = diContainer.resolve('store'); 13 | 14 | createUser({ userName: 'pirosikick' }).then(output => { 15 | store.dispatch( 16 | app.actions.setUser({ id: output.user.id, name: output.user.name }) 17 | ); 18 | ReactDOM.render(, document.getElementById( 19 | 'app' 20 | ) as Element); 21 | }); 22 | -------------------------------------------------------------------------------- /src/client/signUp/operations.ts: -------------------------------------------------------------------------------- 1 | import * as actions from './actions'; 2 | import { IThunkAction } from '../types'; 3 | import { IAction } from './types'; 4 | 5 | export function signUp( 6 | userName: string, 7 | password: string 8 | ): IThunkAction, IAction> { 9 | return async (dispatch, getState, { usecases }) => { 10 | dispatch(actions.signUpStarting()); 11 | 12 | try { 13 | const output = await usecases.signUpWithPassword({ userName, password }); 14 | if (output.succeeded) { 15 | dispatch(actions.signUpDone(output)); 16 | } else { 17 | dispatch(actions.invalidInput(output.code)); 18 | } 19 | } catch (error) { 20 | dispatch(actions.signUpFailed(error)); 21 | } 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/usecases/createTweet/factory.ts: -------------------------------------------------------------------------------- 1 | import { isTweetTextValid } from '@pirosikick/entities'; 2 | import { ICreateTweetFactory } from './interface'; 3 | import InvalidTweetText from './InvalidTweetText'; 4 | import DataAccessError from './DataAccessError'; 5 | 6 | const createTweetFactory: ICreateTweetFactory = dataAccess => { 7 | return async function createTweet(input) { 8 | if (!isTweetTextValid(input.text)) { 9 | throw new InvalidTweetText(); 10 | } 11 | 12 | try { 13 | const tweet = await dataAccess.createTweet(input.userId, input.text); 14 | return { tweet }; 15 | } catch (cause) { 16 | throw new DataAccessError(cause, 'failed to create tweet'); 17 | } 18 | }; 19 | }; 20 | 21 | export default createTweetFactory; 22 | -------------------------------------------------------------------------------- /src/usecases/createRetweet/interface.ts: -------------------------------------------------------------------------------- 1 | import { IRetweet } from '@pirosikick/entities'; 2 | import { IUseCase, IUseCaseFactory } from '../interface'; 3 | 4 | export interface ICreateRetweetInput { 5 | userId: string; 6 | tweetId: string; 7 | } 8 | 9 | export interface ICreateRetweetOutput { 10 | retweet: { 11 | id: string; 12 | userId: string; 13 | tweetId: string; 14 | createdAt: Date; 15 | }; 16 | } 17 | 18 | export interface IDataAccess { 19 | createRetweet: (userId: string, tweetId: string) => Promise; 20 | } 21 | 22 | export type ICreateRetweet = IUseCase< 23 | ICreateRetweetInput, 24 | ICreateRetweetOutput 25 | >; 26 | 27 | export type ICreateRetweetFactory = IUseCaseFactory< 28 | IDataAccess, 29 | ICreateRetweet 30 | >; 31 | -------------------------------------------------------------------------------- /src/entities/index.ts: -------------------------------------------------------------------------------- 1 | export interface IUser { 2 | id: string; 3 | name: string; 4 | createdAt: Date; 5 | } 6 | 7 | export function isUserNameValid(name: string) { 8 | return /^[a-zA-Z0-9_]+$/.test(name); 9 | } 10 | 11 | export interface ITweet { 12 | id: string; 13 | userId: string; 14 | text: string; 15 | createdAt: Date; 16 | } 17 | 18 | export function isTweetTextValid(tweetText: string): boolean { 19 | return tweetText.length > 0 && tweetText.length <= 140; 20 | } 21 | 22 | export interface IRetweet { 23 | id: string; 24 | userId: string; 25 | tweetId: string; 26 | createdAt: Date; 27 | } 28 | 29 | // export interface ITimelineItem { 30 | // tweet: ITweet; 31 | // retweeted: boolean; 32 | // } 33 | // 34 | // export interface ITimeline { 35 | // user: IUser; 36 | // items: ITimelineItem[]; 37 | // } 38 | // 39 | -------------------------------------------------------------------------------- /src/client/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); 4 | 5 | module.exports = { 6 | mode: 'development', 7 | context: __dirname, 8 | entry: './main.tsx', 9 | output: { 10 | path: path.join(__dirname, 'dist'), 11 | filename: 'main.js' 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.tsx?$/, 17 | use: 'ts-loader' 18 | } 19 | ] 20 | }, 21 | resolve: { 22 | plugins: [ 23 | new TsconfigPathsPlugin({ 24 | configFile: path.resolve(__dirname, '../../tsconfig.json') 25 | }) 26 | ], 27 | extensions: ['.ts', '.tsx', '.js', '.json'] 28 | }, 29 | plugins: [ 30 | new HtmlWebpackPlugin({ 31 | template: 'index.html' 32 | }) 33 | ] 34 | }; 35 | -------------------------------------------------------------------------------- /src/client/signIn/operations.ts: -------------------------------------------------------------------------------- 1 | import { IThunkAction } from '../types'; 2 | import { IAction } from './types'; 3 | import { actions as appActions, IAction as IAppAction } from '../app'; 4 | import * as actions from './actions'; 5 | 6 | export function signIn( 7 | userName: string, 8 | password: string 9 | ): IThunkAction, IAction | IAppAction> { 10 | return async (dispatch, getState, { usecases }) => { 11 | dispatch(actions.signInStating()); 12 | try { 13 | const output = await usecases.signInWithPassword({ userName, password }); 14 | if (output.succeeded) { 15 | dispatch(appActions.setUser(output.user)); 16 | dispatch(actions.signInDone(output)); 17 | } else { 18 | dispatch(actions.invalidInput()); 19 | } 20 | } catch (error) { 21 | dispatch(actions.signInFailed(error)); 22 | } 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/usecases/createTweet/interface.ts: -------------------------------------------------------------------------------- 1 | import { ITweet, IUser } from '@pirosikick/entities'; 2 | import { IUseCase, IUseCaseFactory } from '../interface'; 3 | 4 | export interface IDataAccess { 5 | findUserById(id: string): Promise; 6 | createTweet(userId: string, text: string): Promise; 7 | } 8 | 9 | // ユースケースの入力データ 10 | export interface ICreateTweetInput { 11 | userId: string; 12 | text: string; 13 | } 14 | 15 | // ユースケースの出力データ 16 | export interface ICreateTweetOutput { 17 | tweet: { 18 | id: string; 19 | userId: string; 20 | // user: { 21 | // id: string; 22 | // name: string; 23 | // createdAt: Date; 24 | // }; 25 | text: string; 26 | createdAt: Date; 27 | }; 28 | } 29 | 30 | export type ICreateTweet = IUseCase; 31 | 32 | export type ICreateTweetFactory = IUseCaseFactory; 33 | -------------------------------------------------------------------------------- /src/usecases/signInWithPassword/interface.ts: -------------------------------------------------------------------------------- 1 | import { IUseCase, IUseCaseFactory } from '../interface'; 2 | import { IUser } from '@pirosikick/entities'; 3 | 4 | export interface IDataAccess { 5 | verifyPassword(userName: string, password: string): Promise; 6 | } 7 | 8 | export interface ISignInWithPasswordInput { 9 | userName: string; 10 | password: string; 11 | } 12 | 13 | export type ISignInWithPasswordOutput = 14 | | { 15 | succeeded: false; 16 | user: null; 17 | } 18 | | { 19 | succeeded: true; 20 | user: { 21 | id: string; 22 | name: string; 23 | createdAt: Date; 24 | }; 25 | }; 26 | 27 | export type ISignInWithPassword = IUseCase< 28 | ISignInWithPasswordInput, 29 | ISignInWithPasswordOutput 30 | >; 31 | 32 | export type ISignInWithPasswordFactory = IUseCaseFactory< 33 | IDataAccess, 34 | ISignInWithPassword 35 | >; 36 | -------------------------------------------------------------------------------- /src/client/diContainer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createContainer, 3 | asClass, 4 | InjectionMode, 5 | asFunction, 6 | Lifetime 7 | } from 'awilix'; 8 | import * as usecases from '@pirosikick/usecases'; 9 | import { MemoryDataAccess } from '@pirosikick/data-access'; 10 | import { configureStore } from './store'; 11 | 12 | const container = createContainer({ injectionMode: InjectionMode.CLASSIC }); 13 | 14 | container.register({ 15 | dataAccess: asClass(MemoryDataAccess, { lifetime: Lifetime.SINGLETON }), 16 | signUpWithPassword: asFunction(usecases.signUpWithPassword.factory), 17 | signInWithPassword: asFunction(usecases.signInWithPassword.factory), 18 | createUser: asFunction(usecases.createUser.factory), 19 | createTweet: asFunction(usecases.createTweet.factory), 20 | getTweets: asFunction(usecases.getTweets.factory), 21 | store: asFunction(configureStore, { lifetime: Lifetime.SINGLETON }) 22 | }); 23 | 24 | export default container; 25 | -------------------------------------------------------------------------------- /src/usecases/createUser/factory.ts: -------------------------------------------------------------------------------- 1 | import { IUser, isUserNameValid } from '@pirosikick/entities'; 2 | import { ICreateUserFactory } from './interface'; 3 | import DataAccessError from './DataAccessError'; 4 | import InvalidUserName from './InvalidUserName'; 5 | import UserNameDuplicated from './UserNameDuplicated'; 6 | 7 | const createUserFactory: ICreateUserFactory = dataAccess => { 8 | return async function createUser(input) { 9 | if (!isUserNameValid(input.userName)) { 10 | throw new InvalidUserName(); 11 | } 12 | 13 | let user: IUser | null; 14 | try { 15 | user = await dataAccess.findUserByName(input.userName); 16 | } catch (cause) { 17 | throw new DataAccessError(cause, 'failed to find user by name'); 18 | } 19 | 20 | if (user) { 21 | throw new UserNameDuplicated(); 22 | } 23 | 24 | try { 25 | const newUser = await dataAccess.createUser(input.userName); 26 | return { user: newUser }; 27 | } catch (cause) { 28 | throw new DataAccessError(cause, 'failed to create user'); 29 | } 30 | }; 31 | }; 32 | 33 | export default createUserFactory; 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Hiroyuki ANAI 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/usecases/signUpWithPassword/interface.ts: -------------------------------------------------------------------------------- 1 | import { IUser } from '@pirosikick/entities'; 2 | import { IUseCase, IUseCaseFactory } from '../interface'; 3 | 4 | export interface IDataAccess { 5 | findUserByName(userName: string): Promise; 6 | createUserWithPassword(userName: string, password: string): Promise; 7 | } 8 | 9 | export interface ISignUpWithPasswordInput { 10 | userName: string; 11 | password: string; 12 | } 13 | 14 | export enum ErrorCode { 15 | USERNAME_ALREADY_USED = 'USERNAME_ALREADY_USED', 16 | USERNAME_INVALID = 'USERNAME_INVALID', 17 | PASSWORD_INVALID = 'PASSWORD_INVALID' 18 | } 19 | 20 | export type ISignUpWithPasswordOutput = 21 | | { 22 | succeeded: true; 23 | user: { 24 | id: string; 25 | name: string; 26 | createdAt: Date; 27 | }; 28 | } 29 | | { 30 | succeeded: false; 31 | code: ErrorCode; 32 | }; 33 | 34 | export type ISignUpWithPassword = IUseCase< 35 | ISignUpWithPasswordInput, 36 | ISignUpWithPasswordOutput 37 | >; 38 | 39 | export type ISignUpWithPasswordFactory = IUseCaseFactory< 40 | IDataAccess, 41 | ISignUpWithPassword 42 | >; 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | .vscode/ 64 | 65 | dist/ -------------------------------------------------------------------------------- /src/client/types.ts: -------------------------------------------------------------------------------- 1 | import { Store, Action, Reducer } from 'redux'; 2 | import { ThunkAction } from 'redux-thunk'; 3 | import * as usecases from '@pirosikick/usecases'; 4 | import * as app from './app'; 5 | import * as signUp from './signUp'; 6 | import * as signIn from './signIn'; 7 | 8 | export interface IUser { 9 | readonly id: string; 10 | readonly name: string; 11 | } 12 | 13 | export type IAction = app.IAction | signUp.IAction | signIn.IAction; 14 | 15 | export interface IRootState { 16 | app: app.IState; 17 | signUp: signUp.IState; 18 | signIn: signIn.IState; 19 | } 20 | 21 | export type IStore = Store; 22 | 23 | export interface IPayloadAction extends Action { 24 | type: T; 25 | payload: P; 26 | } 27 | 28 | export interface IThunkExtraArgument { 29 | usecases: { 30 | signUpWithPassword: usecases.signUpWithPassword.ISignUpWithPassword; 31 | signInWithPassword: usecases.signInWithPassword.ISignInWithPassword; 32 | getTweets: usecases.getTweets.IGetTweets; 33 | createTweet: usecases.createTweet.ICreateTweet; 34 | }; 35 | } 36 | 37 | export type IThunkAction> = ThunkAction< 38 | R, 39 | IRootState, 40 | IThunkExtraArgument, 41 | A 42 | >; 43 | -------------------------------------------------------------------------------- /src/client/signIn/actions.ts: -------------------------------------------------------------------------------- 1 | import { signInWithPassword as usecase } from '@pirosikick/usecases'; 2 | import { 3 | ActionType, 4 | IChangeUserNameInput, 5 | IChangePasswordInput, 6 | ISignInStartingAction, 7 | IInvalidInputAction, 8 | ISignInDoneAction, 9 | ISignInFailedAction, 10 | IReset 11 | } from './types'; 12 | 13 | export const changeUserNameInput = ( 14 | userNameInput: string 15 | ): IChangeUserNameInput => ({ 16 | type: ActionType.CHANGE_USERNAME_INPUT, 17 | payload: userNameInput 18 | }); 19 | 20 | export const changePasswordInput = ( 21 | passwordInput: string 22 | ): IChangePasswordInput => ({ 23 | type: ActionType.CHANGE_PASSWORD_INPUT, 24 | payload: passwordInput 25 | }); 26 | 27 | export const signInStating = (): ISignInStartingAction => ({ 28 | type: ActionType.SIGNIN_STARTING 29 | }); 30 | 31 | export const invalidInput = (): IInvalidInputAction => ({ 32 | type: ActionType.INVALID_INPUT 33 | }); 34 | 35 | export const signInDone = ( 36 | output: usecase.ISignInWithPasswordOutput 37 | ): ISignInDoneAction => ({ 38 | type: ActionType.SIGNIN_DONE, 39 | payload: output 40 | }); 41 | 42 | export const signInFailed = (error: Error): ISignInFailedAction => ({ 43 | type: ActionType.SIGNIN_FAILED, 44 | payload: error 45 | }); 46 | 47 | export const reset = (): IReset => ({ 48 | type: ActionType.RESET 49 | }); 50 | -------------------------------------------------------------------------------- /src/client/signIn/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Reducer } from 'redux'; 2 | import { IAction, ActionType } from './types'; 3 | 4 | export interface IState { 5 | userNameInput: string; 6 | passwordInput: string; 7 | starting: boolean; 8 | error: 'INVALID_INPUT' | 'SIGNIN_FAILED' | null; 9 | done: boolean; 10 | } 11 | const initialState: IState = { 12 | userNameInput: '', 13 | passwordInput: '', 14 | starting: false, 15 | error: null, 16 | done: false 17 | }; 18 | 19 | const reducer: Reducer = (state = initialState, action) => { 20 | switch (action.type) { 21 | case ActionType.CHANGE_USERNAME_INPUT: 22 | return { ...state, userNameInput: action.payload }; 23 | 24 | case ActionType.CHANGE_PASSWORD_INPUT: 25 | return { ...state, passwordInput: action.payload }; 26 | 27 | case ActionType.SIGNIN_STARTING: 28 | return { ...state, starting: true, error: null }; 29 | 30 | case ActionType.INVALID_INPUT: 31 | return { ...state, starting: false, error: 'INVALID_INPUT' }; 32 | 33 | case ActionType.SIGNIN_DONE: 34 | return { ...state, done: true }; 35 | 36 | case ActionType.SIGNIN_FAILED: 37 | return { ...state, starting: false, error: 'SIGNIN_FAILED' }; 38 | 39 | case ActionType.RESET: 40 | return initialState; 41 | } 42 | return state; 43 | }; 44 | 45 | export default reducer; 46 | -------------------------------------------------------------------------------- /src/client/signUp/actions.ts: -------------------------------------------------------------------------------- 1 | import { signUpWithPassword as usecase } from '@pirosikick/usecases'; 2 | import { 3 | ActionType, 4 | IChangeUserNameInputAction, 5 | IChangePasswordInputAction, 6 | IInvalidInputAction, 7 | ISignUpStartingAction, 8 | ISignUpDoneAction, 9 | ISignUpFailedAction, 10 | IReset 11 | } from './types'; 12 | 13 | export const changeUserNameInput = ( 14 | userNameInput: string 15 | ): IChangeUserNameInputAction => ({ 16 | type: ActionType.CHANGE_USERNAME_INPUT, 17 | payload: userNameInput 18 | }); 19 | 20 | export const changePasswordInput = ( 21 | passwordInput: string 22 | ): IChangePasswordInputAction => ({ 23 | type: ActionType.CHANGE_PASSWORD_INPUT, 24 | payload: passwordInput 25 | }); 26 | 27 | export const invalidInput = (code: usecase.ErrorCode): IInvalidInputAction => ({ 28 | type: ActionType.INVALID_INPUT, 29 | payload: code 30 | }); 31 | 32 | export const signUpStarting = (): ISignUpStartingAction => ({ 33 | type: ActionType.SIGNUP_STARTING 34 | }); 35 | 36 | export const signUpDone = ( 37 | output: usecase.ISignUpWithPasswordOutput 38 | ): ISignUpDoneAction => ({ 39 | type: ActionType.SIGNUP_DONE, 40 | payload: output 41 | }); 42 | 43 | export const signUpFailed = (error: Error): ISignUpFailedAction => ({ 44 | type: ActionType.SIGNUP_FAILED, 45 | payload: error 46 | }); 47 | 48 | export const reset = (): IReset => ({ type: ActionType.RESET }); 49 | -------------------------------------------------------------------------------- /src/client/signUp/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Reducer } from 'redux'; 2 | import { ActionType, IAction } from './types'; 3 | import { signUpWithPassword as usecase } from '@pirosikick/usecases'; 4 | 5 | export interface IState { 6 | userNameInput: string; 7 | passwordInput: string; 8 | starting: boolean; 9 | done: boolean; 10 | errorCode: usecase.ErrorCode | null; 11 | } 12 | const initialState: IState = { 13 | userNameInput: '', 14 | passwordInput: '', 15 | starting: false, 16 | done: false, 17 | errorCode: null 18 | }; 19 | 20 | const reducer: Reducer = (state = initialState, action) => { 21 | switch (action.type) { 22 | case ActionType.CHANGE_USERNAME_INPUT: 23 | return { ...state, userNameInput: action.payload }; 24 | 25 | case ActionType.CHANGE_PASSWORD_INPUT: 26 | return { ...state, passwordInput: action.payload }; 27 | 28 | case ActionType.SIGNUP_STARTING: 29 | return { ...state, starting: true }; 30 | 31 | case ActionType.INVALID_INPUT: 32 | return { ...state, starting: false, errorCode: action.payload }; 33 | 34 | case ActionType.SIGNUP_DONE: 35 | return { ...state, starting: false, done: true }; 36 | 37 | case ActionType.SIGNUP_FAILED: 38 | return { ...state, starting: false }; 39 | 40 | case ActionType.RESET: 41 | return initialState; 42 | 43 | default: 44 | return state; 45 | } 46 | }; 47 | 48 | export default reducer; 49 | -------------------------------------------------------------------------------- /src/client/store.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { applyMiddleware, compose, createStore } from 'redux'; 3 | import thunkMiddleware from 'redux-thunk'; 4 | import * as usecases from '@pirosikick/usecases'; 5 | import { IRootState, IStore, IThunkExtraArgument } from './types'; 6 | import * as app from './app'; 7 | import * as signUp from './signUp'; 8 | import * as signIn from './signIn'; 9 | 10 | const reducer = combineReducers({ 11 | app: app.reducer, 12 | signUp: signUp.reducer, 13 | signIn: signIn.reducer 14 | }); 15 | 16 | export function configureStore( 17 | signUpWithPassword: usecases.signUpWithPassword.ISignUpWithPassword, 18 | signInWithPassword: usecases.signInWithPassword.ISignInWithPassword, 19 | getTweets: usecases.getTweets.IGetTweets, 20 | createTweet: usecases.createTweet.ICreateTweet 21 | ): IStore { 22 | const composeEnhancers = 23 | process.env.NODE_ENV !== 'production' 24 | ? ((window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ as typeof compose) 25 | : compose; 26 | 27 | const enhancers = composeEnhancers( 28 | applyMiddleware( 29 | thunkMiddleware.withExtraArgument({ 30 | usecases: { 31 | signUpWithPassword, 32 | signInWithPassword, 33 | getTweets, 34 | createTweet 35 | } 36 | }) 37 | ) 38 | ); 39 | 40 | return createStore(reducer, undefined, enhancers); 41 | } 42 | -------------------------------------------------------------------------------- /src/usecases/signUpWithPassword/factory.ts: -------------------------------------------------------------------------------- 1 | import { ISignUpWithPasswordFactory, ErrorCode } from './interface'; 2 | import DataAccessError from './DataAccessError'; 3 | import { isUserNameValid } from '@pirosikick/entities'; 4 | 5 | const signUpWithPasswordFactory: ISignUpWithPasswordFactory = dataAccess => { 6 | return async function signUpWithPassword(input) { 7 | if (!isUserNameValid(input.userName)) { 8 | return { 9 | succeeded: false, 10 | code: ErrorCode.USERNAME_INVALID 11 | }; 12 | } 13 | 14 | // TODO 15 | if (input.password.length < 8) { 16 | return { 17 | succeeded: false, 18 | code: ErrorCode.PASSWORD_INVALID 19 | }; 20 | } 21 | 22 | try { 23 | const user = await dataAccess.findUserByName(input.userName); 24 | if (user) { 25 | return { 26 | succeeded: false, 27 | code: ErrorCode.USERNAME_ALREADY_USED 28 | }; 29 | } 30 | } catch (cause) { 31 | throw new DataAccessError(cause, 'failed to find user by name'); 32 | } 33 | 34 | try { 35 | const user = await dataAccess.createUserWithPassword( 36 | input.userName, 37 | input.password 38 | ); 39 | return { succeeded: true, user }; 40 | } catch (cause) { 41 | throw new DataAccessError(cause, 'failed to create user with password'); 42 | } 43 | }; 44 | }; 45 | 46 | export default signUpWithPasswordFactory; 47 | -------------------------------------------------------------------------------- /src/client/signIn/types.ts: -------------------------------------------------------------------------------- 1 | import { signInWithPassword as usecase } from '@pirosikick/usecases'; 2 | import { IPayloadAction } from '../types'; 3 | import { Action } from 'redux'; 4 | 5 | export enum ActionType { 6 | CHANGE_USERNAME_INPUT = 'app/signIn/CHANGE_USERNAME_INPUT', 7 | CHANGE_PASSWORD_INPUT = 'app/signIn/CHANGE_PASSWORD_INPUT', 8 | INVALID_INPUT = 'app/signIn/INVALID_INPUT', 9 | SIGNIN_STARTING = 'app/signIn/SIGNIN_STARTING', 10 | SIGNIN_DONE = 'app/signIn/SIGNIN_DONE', 11 | SIGNIN_FAILED = 'app/signIn/SINGUP_FAILED', 12 | RESET = 'app/signIn/RESET' 13 | } 14 | 15 | export type IChangeUserNameInput = IPayloadAction< 16 | ActionType.CHANGE_USERNAME_INPUT, 17 | string 18 | >; 19 | 20 | export type IChangePasswordInput = IPayloadAction< 21 | ActionType.CHANGE_PASSWORD_INPUT, 22 | string 23 | >; 24 | 25 | export type ISignInStartingAction = Action; 26 | 27 | export type IInvalidInputAction = Action; 28 | 29 | export type ISignInDoneAction = IPayloadAction< 30 | ActionType.SIGNIN_DONE, 31 | usecase.ISignInWithPasswordOutput 32 | >; 33 | 34 | export type ISignInFailedAction = IPayloadAction< 35 | ActionType.SIGNIN_FAILED, 36 | Error 37 | >; 38 | 39 | export type IReset = Action; 40 | 41 | export type IAction = 42 | | IChangeUserNameInput 43 | | IChangePasswordInput 44 | | IInvalidInputAction 45 | | ISignInStartingAction 46 | | ISignInDoneAction 47 | | ISignInFailedAction 48 | | IReset; 49 | -------------------------------------------------------------------------------- /src/client/signUp/types.ts: -------------------------------------------------------------------------------- 1 | import { signUpWithPassword as usecase } from '@pirosikick/usecases'; 2 | import { IPayloadAction } from '../types'; 3 | import { Action } from 'redux'; 4 | 5 | export enum ActionType { 6 | CHANGE_USERNAME_INPUT = 'app/signUp/CHANGE_USERNAME_INPUT', 7 | CHANGE_PASSWORD_INPUT = 'app/signUp/CHANGE_PASSWORD_INPUT', 8 | INVALID_INPUT = 'app/signUp/INVALID_INPUT', 9 | SIGNUP_STARTING = 'app/signUp/SIGNUP_STARTING', 10 | SIGNUP_DONE = 'app/signUp/SIGNUP_DONE', 11 | SIGNUP_FAILED = 'app/signUp/SINGUP_FAILED', 12 | RESET = 'app/signUp/RESET' 13 | } 14 | 15 | export type IChangeUserNameInputAction = IPayloadAction< 16 | ActionType.CHANGE_USERNAME_INPUT, 17 | string 18 | >; 19 | 20 | export type IChangePasswordInputAction = IPayloadAction< 21 | ActionType.CHANGE_PASSWORD_INPUT, 22 | string 23 | >; 24 | 25 | export type IInvalidInputAction = IPayloadAction< 26 | ActionType.INVALID_INPUT, 27 | usecase.ErrorCode 28 | >; 29 | 30 | export type ISignUpStartingAction = Action; 31 | 32 | export type ISignUpDoneAction = IPayloadAction< 33 | ActionType.SIGNUP_DONE, 34 | usecase.ISignUpWithPasswordOutput 35 | >; 36 | 37 | export type ISignUpFailedAction = IPayloadAction< 38 | ActionType.SIGNUP_FAILED, 39 | Error 40 | >; 41 | 42 | export type IReset = Action; 43 | 44 | export type IAction = 45 | | IChangeUserNameInputAction 46 | | IChangePasswordInputAction 47 | | IInvalidInputAction 48 | | ISignUpStartingAction 49 | | ISignUpDoneAction 50 | | ISignUpFailedAction 51 | | IReset; 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twitter-like-app-clean-architecture", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/pirosikick/twitter-like-app-clean-architecture.git" 12 | }, 13 | "author": "", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/pirosikick/twitter-like-app-clean-architecture/issues" 17 | }, 18 | "homepage": "https://github.com/pirosikick/twitter-like-app-clean-architecture#readme", 19 | "devDependencies": { 20 | "@types/express": "^4.16.0", 21 | "html-webpack-plugin": "^3.2.0", 22 | "husky": "^1.2.0", 23 | "jest": "^23.6.0", 24 | "lint-staged": "^8.1.0", 25 | "prettier": "^1.15.2", 26 | "ts-jest": "^23.10.5", 27 | "ts-loader": "^5.3.1", 28 | "ts-node": "^7.0.1", 29 | "tsconfig-paths": "^3.7.0", 30 | "tsconfig-paths-webpack-plugin": "^3.2.0", 31 | "tslint": "^5.11.0", 32 | "tslint-config-prettier": "^1.16.0", 33 | "typescript": "^3.1.6", 34 | "webpack": "^4.27.1", 35 | "webpack-cli": "^3.1.2", 36 | "webpack-dev-server": "^3.1.10" 37 | }, 38 | "husky": { 39 | "hooks": { 40 | "pre-commit": "lint-staged" 41 | } 42 | }, 43 | "lint-staged": { 44 | "*.{ts,tsx}": [ 45 | "tslint --fix", 46 | "prettier --write", 47 | "git add" 48 | ], 49 | "*.{js,json,md}": [ 50 | "prettier --write", 51 | "git add" 52 | ] 53 | }, 54 | "dependencies": { 55 | "@types/body-parser": "^1.17.0", 56 | "@types/express-session": "^1.15.11", 57 | "@types/handlebars": "^4.0.39", 58 | "@types/jest": "^23.3.10", 59 | "@types/node": "^8.10.38", 60 | "@types/react": "^16.7.13", 61 | "@types/react-dom": "^16.0.11", 62 | "@types/react-redux": "^6.0.10", 63 | "@types/react-router-dom": "^4.3.1", 64 | "@types/recompose": "^0.30.0", 65 | "@types/styled-components": "^4.1.3", 66 | "@types/uuid": "^3.4.4", 67 | "@zeit/next-typescript": "^1.1.1", 68 | "awilix": "^4.0.1", 69 | "body-parser": "^1.18.3", 70 | "express": "^4.16.4", 71 | "express-session": "^1.15.6", 72 | "handlebars": "^4.0.12", 73 | "history": "^4.7.2", 74 | "next": "^7.0.2", 75 | "react": "^16.6.3", 76 | "react-dom": "^16.6.3", 77 | "react-redux": "^6.0.0", 78 | "react-router-dom": "^4.3.1", 79 | "recompose": "^0.30.0", 80 | "redux": "^4.0.1", 81 | "redux-thunk": "^2.3.0", 82 | "styled-components": "^4.1.2", 83 | "typesafe-actions": "^2.0.4", 84 | "uuid": "^3.3.2" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/client/pages/SignUp.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Redirect } from 'react-router-dom'; 3 | import { connect } from 'react-redux'; 4 | import { Dispatch, bindActionCreators } from 'redux'; 5 | import { signUpWithPassword as usecase } from '@pirosikick/usecases'; 6 | import { IRootState } from '../types'; 7 | import { actions, operations, IState } from '../signUp'; 8 | 9 | const errorMessages = { 10 | [usecase.ErrorCode.USERNAME_ALREADY_USED]: 11 | '入力したユーザー名は既に利用されています。', 12 | [usecase.ErrorCode.USERNAME_INVALID]: 'ユーザー名は英数字1文字以上です。', 13 | [usecase.ErrorCode.PASSWORD_INVALID]: 'パスワードは8文字以上必要です。' 14 | }; 15 | 16 | interface IProps { 17 | userNameInput: string; 18 | passwordInput: string; 19 | disabled: boolean; 20 | errorCode: IState['errorCode']; 21 | done: boolean; 22 | onChangeUserNameInput: (userNameInput: string) => void; 23 | onChangePasswordInput: (passwordInput: string) => void; 24 | onSubmit: () => void; 25 | onWillUnmount: () => void; 26 | } 27 | 28 | class SignUp extends React.Component { 29 | public componentWillUnmount() { 30 | this.props.onWillUnmount(); 31 | } 32 | 33 | public handleSubmit = (event: React.FormEvent) => { 34 | event.preventDefault(); 35 | this.props.onSubmit(); 36 | }; 37 | 38 | public handleChangeUserNameInput = ( 39 | event: React.ChangeEvent 40 | ) => { 41 | this.props.onChangeUserNameInput(event.target.value); 42 | }; 43 | 44 | public handleChangePasswordInput = ( 45 | event: React.ChangeEvent 46 | ) => { 47 | this.props.onChangePasswordInput(event.target.value); 48 | }; 49 | 50 | public render() { 51 | if (this.props.done) { 52 | return ; 53 | } 54 | 55 | const { errorCode, userNameInput, passwordInput, disabled } = this.props; 56 | return ( 57 |
58 |

サインアップ

59 | {errorCode &&

{errorMessages[errorCode]}

} 60 |
61 |

62 | ユーザ名:{' '} 63 | 69 |

70 |

71 | パスワード:{' '} 72 | 78 |

79 | 82 |
83 |
84 | ); 85 | } 86 | } 87 | 88 | const mapStateToProps = (state: IRootState) => ({ 89 | userNameInput: state.signUp.userNameInput, 90 | passwordInput: state.signUp.passwordInput, 91 | disabled: state.signUp.starting, 92 | errorCode: state.signUp.errorCode, 93 | done: state.signUp.done 94 | }); 95 | 96 | const mapDispatchToProps = (dispatch: Dispatch) => ({ 97 | ...bindActionCreators( 98 | { 99 | changeUserNameInput: actions.changeUserNameInput, 100 | changePasswordInput: actions.changePasswordInput, 101 | reset: actions.reset, 102 | signUp: operations.signUp 103 | }, 104 | dispatch 105 | ) 106 | }); 107 | 108 | const mergeProps = ( 109 | state: ReturnType, 110 | actionCreators: ReturnType 111 | ) => ({ 112 | ...state, 113 | onChangeUserNameInput: actionCreators.changeUserNameInput, 114 | onChangePasswordInput: actionCreators.changePasswordInput, 115 | onSubmit: () => { 116 | actionCreators.signUp(state.userNameInput, state.passwordInput); 117 | }, 118 | onWillUnmount: actionCreators.reset 119 | }); 120 | 121 | export default connect( 122 | mapStateToProps, 123 | mapDispatchToProps, 124 | mergeProps 125 | )(SignUp); 126 | -------------------------------------------------------------------------------- /src/client/pages/SignIn.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Redirect } from 'react-router'; 3 | import { Dispatch, bindActionCreators } from 'redux'; 4 | import { IRootState } from '../types'; 5 | import { actions, operations, IState } from '../signIn'; 6 | import { connect } from 'react-redux'; 7 | 8 | interface IProps { 9 | userNameInput: string; 10 | passwordInput: string; 11 | disabled: boolean; 12 | error: IState['error']; 13 | done: boolean; 14 | onWillUnmount: () => void; 15 | onChangeUserNameInput: (userNameInput: string) => void; 16 | onChangePasswordInput: (passwordInput: string) => void; 17 | onSubmit: () => void; 18 | } 19 | 20 | class SignIn extends React.Component { 21 | public componentWillUnmount() { 22 | this.props.onWillUnmount(); 23 | } 24 | 25 | public handleSubmit = (event: React.FormEvent) => { 26 | event.preventDefault(); 27 | this.props.onSubmit(); 28 | }; 29 | 30 | public handleChangeUserNameInput = ( 31 | event: React.ChangeEvent 32 | ) => { 33 | this.props.onChangeUserNameInput(event.target.value); 34 | }; 35 | 36 | public handleChangePasswordInput = ( 37 | event: React.ChangeEvent 38 | ) => { 39 | this.props.onChangePasswordInput(event.target.value); 40 | }; 41 | 42 | public render() { 43 | if (this.props.done) { 44 | return ; 45 | } 46 | 47 | const { userNameInput, passwordInput, disabled, error } = this.props; 48 | return ( 49 |
50 |

サインイン

51 | {error && 52 | (error === 'INVALID_INPUT' ? ( 53 |

入力内容に誤りがあります。

54 | ) : ( 55 |

サインインに失敗しました

56 | ))} 57 |
58 |

59 | ユーザ名:{' '} 60 | 66 |

67 |

68 | パスワード:{' '} 69 | 75 |

76 | 79 |
80 |
81 | ); 82 | } 83 | } 84 | 85 | const mapStateToProps = (state: IRootState) => ({ 86 | userNameInput: state.signIn.userNameInput, 87 | passwordInput: state.signIn.passwordInput, 88 | starting: state.signIn.starting, 89 | error: state.signIn.error, 90 | done: state.signIn.done, 91 | disabled: state.signIn.starting 92 | }); 93 | 94 | // const mapDispatchToProps = { 95 | // changeUserNameInput: actions.changeUserNameInput, 96 | // changePasswordInput: actions.changePasswordInput, 97 | // reset: actions.reset, 98 | // signIn: operations.signIn 99 | // }; 100 | const mapDispatchToProps = (dispatch: Dispatch) => ({ 101 | ...bindActionCreators( 102 | { 103 | changeUserNameInput: actions.changeUserNameInput, 104 | changePasswordInput: actions.changePasswordInput, 105 | signIn: operations.signIn, 106 | reset: actions.reset 107 | }, 108 | dispatch 109 | ) 110 | }); 111 | 112 | const mergeProps = ( 113 | state: ReturnType, 114 | actionCreators: ReturnType 115 | ) => ({ 116 | ...state, 117 | onChangeUserNameInput: actionCreators.changeUserNameInput, 118 | onChangePasswordInput: actionCreators.changePasswordInput, 119 | onSubmit() { 120 | actionCreators.signIn(state.userNameInput, state.passwordInput); 121 | }, 122 | onWillUnmount: actionCreators.reset 123 | }); 124 | type MergeParams = ReturnType; 125 | 126 | export default connect( 127 | mapStateToProps, 128 | mapDispatchToProps, 129 | mergeProps 130 | )(SignIn); 131 | -------------------------------------------------------------------------------- /src/data-access/MemoryDataAccess.ts: -------------------------------------------------------------------------------- 1 | import * as entities from '@pirosikick/entities'; 2 | import * as usecases from '@pirosikick/usecases'; 3 | import uuid from 'uuid/v4'; 4 | 5 | interface IUserPasswordCredential { 6 | userId: string; 7 | password: string; 8 | } 9 | 10 | export default class MemoryDataAccess 11 | implements 12 | // ユースケースのDetaAccessを実装 13 | usecases.signUpWithPassword.IDataAccess, 14 | usecases.signInWithPassword.IDataAccess, 15 | usecases.createTweet.IDataAccess, 16 | usecases.createRetweet.IDataAccess, 17 | usecases.getTweets.IDataAccess, 18 | usecases.createUser.IDataAccess { 19 | // メモリ上(変数)にデータを保存 20 | private users: entities.IUser[] = []; 21 | private userPasswordCredentials: IUserPasswordCredential[] = []; 22 | private tweets: entities.ITweet[] = []; 23 | private retweets: entities.IRetweet[] = []; 24 | 25 | public createUser(name: string): Promise { 26 | const user = { 27 | id: uuid(), 28 | name, 29 | createdAt: new Date() 30 | }; 31 | 32 | this.users.push(user); 33 | return Promise.resolve(user); 34 | } 35 | 36 | public findUserById(id: string): Promise { 37 | const user = this.users.find(u => u.id === id); 38 | return Promise.resolve(user || null); 39 | } 40 | 41 | public findUserByName(name: string): Promise { 42 | const user = this.users.find(u => u.name === name); 43 | return Promise.resolve(user || null); 44 | } 45 | 46 | public createTweet(userId: string, text: string): Promise { 47 | const user = this.users.find(u => u.id === userId); 48 | if (!user) { 49 | throw new Error(`user whose id is "${userId}" not exists`); 50 | } 51 | 52 | const tweet = { 53 | id: uuid(), 54 | userId: user.id, 55 | text, 56 | createdAt: new Date() 57 | }; 58 | this.tweets.push(tweet); 59 | return Promise.resolve(tweet); 60 | } 61 | 62 | public createRetweet( 63 | userId: string, 64 | tweetId: string 65 | ): Promise { 66 | const user = this.users.find(u => u.id === userId); 67 | if (!user) { 68 | throw new Error(`user whose id is "${userId}" not exists`); 69 | } 70 | 71 | const tweet = this.findTweetById(tweetId); 72 | if (!tweet) { 73 | throw new Error(`tweet whose id is "${tweetId}" not exists`); 74 | } 75 | 76 | const id = uuid(); 77 | const createdAt = new Date(); 78 | const retweet = { 79 | id: uuid(), 80 | userId: user.id, 81 | tweetId: tweet.id, 82 | createdAt: new Date() 83 | }; 84 | this.retweets.push(retweet); 85 | return Promise.resolve(retweet); 86 | } 87 | 88 | public findTweetsByUserName(userName: string): Promise { 89 | const user = this.users.find(u => u.name === userName); 90 | if (!user) { 91 | throw new Error(`use whose name is "${userName}" not exists`); 92 | } 93 | 94 | const tweets = this.tweets.filter(tweet => tweet.userId === user.id); 95 | 96 | return Promise.resolve(tweets); 97 | } 98 | 99 | public createUserWithPassword( 100 | userName: string, 101 | password: string 102 | ): Promise { 103 | const user = { 104 | id: uuid(), 105 | name: userName, 106 | createdAt: new Date() 107 | }; 108 | const userPasswordCredential = { 109 | userId: user.id, 110 | password 111 | }; 112 | 113 | this.users.push(user); 114 | this.userPasswordCredentials.push(userPasswordCredential); 115 | 116 | return Promise.resolve(user); 117 | } 118 | 119 | public verifyPassword( 120 | userName: string, 121 | password: string 122 | ): Promise { 123 | const user = this.users.find(u => u.name === userName); 124 | if (!user) { 125 | return Promise.resolve(null); 126 | } 127 | 128 | const credential = this.userPasswordCredentials.find( 129 | c => c.userId === user.id 130 | ); 131 | if (!credential) { 132 | throw new Error(`the credential whose userId = '${user.id}' not exists`); 133 | } 134 | 135 | return Promise.resolve(credential.password === password ? user : null); 136 | } 137 | 138 | private findTweetById(id: string): entities.ITweet | null { 139 | const tweet = this.tweets.find(t => t.id === id); 140 | return tweet || null; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */, 5 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 6 | "lib": [ 7 | "dom", 8 | "ES2015" 9 | ] /* Specify library files to be included in the compilation. */, 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | // "outDir": "./", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "removeComments": true, /* Do not emit comments to output. */ 21 | // "noEmit": true, /* Do not emit outputs. */ 22 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 23 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 24 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 25 | 26 | /* Strict Type-Checking Options */ 27 | "strict": true /* Enable all strict type-checking options. */, 28 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 29 | // "strictNullChecks": true, /* Enable strict null checks. */ 30 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | 35 | /* Additional Checks */ 36 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | 41 | /* Module Resolution Options */ 42 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | "baseUrl": "./" /* Base directory to resolve non-absolute module names. */, 44 | "paths": { 45 | "@pirosikick/*": ["./src/*"] 46 | } /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */, 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": ["./node_modules/@types"], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | 54 | /* Source Map Options */ 55 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 56 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 57 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 58 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 59 | 60 | /* Experimental Options */ 61 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 62 | // "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */ 63 | } 64 | } 65 | --------------------------------------------------------------------------------