A React, Redux & Resolve Hacker News implementation
Join us:
git clone https://github.com/reimagined/hacker-news-resolve.git
cd hacker-news-resolve
npm install
npm run dev
Starts the app in development mode. Provides hot reloading, source mapping and other development capabilities.
npm run build
npm start
Starts the application in production mode.
After you run the application you can view it at http://localhost:3000/.
npm run import
Imports data (up to 500 stories with comments) from HackerNews.
Press Crtl-C
to stop importing or wait until it is finished.
This tutorial shows you how to create a Hacker News application and consists of the following steps:
- Requirements
- Creating a New ReSolve Application
- Domain Model Analysis
- Adding Users
- Adding Stories
- Adding Comments
- Data Importer
This demo is implemented using the reSolve framework. You need to be familiar with React and Redux, as well as with DDD, CQRS and Event Sourcing. If you are new to these concepts, refer to the following links to learn the basics:
- Event Sourcing
- Command/Query Responsibility Segregation and Domain-Driven Design
- React
- Redux
- GraphQL
You can also read the following articles for more information:
- Why using DDD, CQRS and Event Sourcing
- Building Scalable Applications Using Event Sourcing and CQRS
- May the source be with you
- Using logs to build a solid data infrastructure
- node 8.2.0, or later
- npm 5.3.0, or later
Use the create-resolve-app CLI tool to create a new reSolve project.
Install create-resolve-app globally.
npm i -g create-resolve-app
Create an empty reSolve project and run the application in the development mode.
create-resolve-app hn-resolve
cd hn-resolve
npm run dev
The application opens in the browser at http://localhost:3000/.
After the installation is completed, your project has the default structure:
At this point, you need to analyze the domain. Event Sourcing and CQRS require identifying Domain Aggregates and their corresponding commands and events which are used to build the required read models.
Hacker News is a social news website focusing on computer science. Its users can post news, ask questions, comment on news, and reply to comments. These posts are called Stories.
Users can post Stories and Comments.
- Story - news or question
- Comment - a short message written about news or question
- User - a registered and logged in user that can perform actions (post news, ask questions, write comments)
Next, identify domain aggregate roots by detecting which commands the Hacker News application should perform and which entities they are addressed to:
- create a User
- create a Story
- comment a Story
- upvote a Story
- unvote a Story
You only need the User and Story aggregate roots since there are no commands addressed to Comment. Note that when using CQRS and Event Sourcing, you need to identify which events should be captured on the Write Side, and then compose a list of Read Side models from these events.
To summarize the domain analysis:
There are two aggregate roots - User and Story with the following commands and events:
- User
- CreateUser generates the UserCreated event
- Story
- CreateStory generates the StoryCreated event
- CommentStory generates the StoryCommented event
- UpvoteStory generates the StoryUpvoted event
- UnvoteStory generates the StoryUnvoted event
Add user registration and authentication functionality to the application. For demo purposes, we omitted password checking. If needed, you can implement hashing and storing passwords later.
A user has the following fields:
- id - a unique user ID automatically created on the server side
- name - a unique user name which a user provides in the registration form
- createdAt - the user's registration timestamp
Create a user aggregate. Implement event types:
// ./common/events.js
export const USER_CREATED = 'UserCreated'
Add the createUser
command that should return the UserCreated
event.
Validate input data to ensure that a user name exists and it is not empty.
Add projection handlers and an initial state to check whether a user already exists.
// ./common/aggregates/validation.js
export default {
stateIsAbsent: (state, type) => {
if (Object.keys(state).length > 0) {
throw new Error(`${type} already exists`)
}
},
fieldRequired: (payload, field) => {
if (!payload[field]) {
throw new Error(`The "${field}" field is required`)
}
}
}
// ./common/aggregates/user.js
// @flow
import { USER_CREATED } from '../events'
import validate from './validation'
export default {
name: 'user',
initialState: {},
commands: {
createUser: (state: any, command: any) => {
validate.stateIsAbsent(state, 'User')
const { name } = command.payload
validate.fieldRequired(command.payload, 'name')
const payload: UserCreatedPayload = { name }
return { type: USER_CREATED, payload }
}
},
projection: {
[USER_CREATED]: (state, { timestamp }) => ({
...state,
createdAt: timestamp
})
}
}
Add aggregate for passing to the config.
// ./common/aggregates/index.js
import user from './user'
export default [user]
Implement a read side. The simplest way to store users is using a store collection.
// ./common/read-models/graphql/projection.js
// @flow
import {
USER_CREATED
} from '../../events'
export default {
Init: async store => {
await store.defineStorage('Users', [
{ name: 'id', type: 'string', index: 'primary' },
{ name: 'name', type: 'string', index: 'secondary' },
{ name: 'createdAt', type: 'number' }
])
},
[USER_CREATED]: async (
store,
{ aggregateId, timestamp, payload: { name } }
) => {
const user = {
id: aggregateId,
name,
createdAt: timestamp
}
await store.insert('Users', user)
}
}
Describe a schema and implement resolvers to get data using GraphQL:
// ./common/read-models/graphql/schema.js
export default `
type User {
id: ID!
name: String
createdAt: String
}
type Query {
user(id: ID, name: String): User
}
`
Implement resolvers:
// ./common/read-models/graphql/resolvers.js
export default {
user: async (store, { id, name }) => {
const user = id
? await store.find('Users', { id })
: await store.find('Users', { name })
return user.length > 0 ? user[0] : null
}
}
Export the read model's GraphQL parts from the graphql
folder root:
// ./common/read-models/graphql/index.js
import projection from './projection'
import gqlResolvers from './resolvers'
import gqlSchema from './schema'
export default {
projection,
gqlSchema,
gqlResolvers
}
Update the read-models
folder export:
// ./common/read-models/index.js
import graphqlReadModel from './graphql'
export default [graphqlReadModel]
After adding a storage for users, create the local authentication strategy and implement the required callbacks.
Install uuid
package:
npm install --save uuid
In the auth/
directory, create ./auth/localStrategy.js
file. passwordField
has same value as usernameField
because this app does not use a password.
// ./auth/localStrategy.js
export default {
strategy: {
usernameField: 'username',
passwordField: 'username',
successRedirect: null
},
registerCallback: async ({ resolve, body }, username, password) => {
// ...
},
loginCallback: async ({ resolve, body }, username, password) => {
// ...
}
}
Implement the getUserByName
function that uses the executeQuery
function passed with registerCallback
and loginCallback
:
// ./auth/localStrategy.js
const getUserByName = async (executeQuery, name) => {
const { user } = await executeQuery(
`query ($name: String!) {
user(name: $name) {
id,
name,
createdAt
}
}`,
{ name: name.trim() }
)
return user
}
export default {
strategy: {
usernameField: 'username',
passwordField: 'username',
successRedirect: null
},
registerCallback: async ({ resolve, body }, username, password) => {
// ...
},
loginCallback: async ({ resolve, body }, username, password) => {
// ...
}
}
Add the required authentication parameters:
// ./auth/constants.js
export const authenticationSecret = 'auth-secret'
export const cookieName = 'authenticationToken'
export const cookieMaxAge = 1000 * 60 * 60 * 24 * 365
Update the registerCallback
and loginCallback
callbacks. Use the resolve
parameter to access the query and command executors.
Add failureCallback
function to provide the redirection path in case of a failure:
// ./auth/localStrategy.js
import uuid from 'uuid'
const getUserByName = async (executeQuery, name) => {
const { user } = await executeQuery(
`query ($name: String!) {
user(name: $name) {
id,
name,
createdAt
}
}`,
{ name: name.trim() }
)
return user
}
export default {
strategy: {
usernameField: 'username',
passwordField: 'username',
successRedirect: null
},
registerCallback: async ({ resolve, body }, username, password) => {
const executeQuery = resolve.queryExecutors.graphql
const existingUser = await getUserByName(executeQuery, username)
if (existingUser) {
throw new Error('User already exists')
}
const user = {
name: username.trim(),
id: uuid.v4()
}
await resolve.executeCommand({
type: 'createUser',
aggregateId: user.id,
aggregateName: 'user',
payload: user
})
return user
},
loginCallback: async ({ resolve, body }, username, password) => {
const user = await getUserByName(resolve.queryExecutors.graphql, username)
if (!user) {
throw new Error('No such user')
}
return user
},
failureCallback: (error, redirect, { resolve, body }) => {
redirect(`/error?text=${error}`)
}
}
Install jsonwebtoken
package in order to get user from cookies.
npm install --save jsonwebtoken
Add the me
resolver to pass a user to the client side.
// ./commmon/read-models/graphql/resolvers.js
import jwt from 'jsonwebtoken'
export default {
// user implementation
me: async (store, _, { jwtToken }) => {
if (!jwtToken) {
return null
}
const user = await jwt.verify(
jwtToken,
process.env.JWT_SECRET || 'DefaultSecret'
)
return user
}
}
Update graphql schema
// ./common/read-models/graphql/schema.js
export default `
type User {
id: ID!
name: String
createdAt: String
}
type Query {
user(id: ID, name: String): User
me: User
}
`
Pass the authentication and JWT parameters to the server config:
// ./resolve.server.config.js
import path from 'path'
import fileAdapter from 'resolve-storage-lite'
import busAdapter from 'resolve-bus-memory'
import { localStrategy } from 'resolve-scripts-auth'
import aggregates from './common/aggregates'
import readModels from './common/read-models'
import clientConfig from './resolve.client.config'
import localStrategyParams from './auth/localStrategy'
import {
authenticationSecret,
cookieName,
cookieMaxAge
} from './auth/constants'
if (module.hot) {
module.hot.accept()
}
const { NODE_ENV = 'development' } = process.env
const dbPath = path.join(__dirname, `${NODE_ENV}.db`)
export default {
entries: clientConfig,
bus: { adapter: busAdapter },
storage: {
adapter: fileAdapter,
params: { pathToFile: dbPath }
},
aggregates,
initialSubscribedEvents: { types: [], ids: [] },
readModels,
jwtCookie: {
name: cookieName,
maxAge: cookieMaxAge,
httpOnly: false
},
auth: {
strategies: [localStrategy(localStrategyParams)]
}
}
Now the server side works with users: a user can be registered and authenticated.
Install the following packages:
query-string
- to parse thesearch
location part
npm install --save query-string
Implement the Error component to display error messages.
The app layout contains meta information, an application header with a menu, user info and some content.
Install the following packages:
react-helmet
- to pass meta information to the HTML headerreact-router
- to implement routingredux
andreact-redux
- to store dataseamless-immutable
- to enforce state immutabilityjs-cookie
- to manipulate cookiesstyled-components
- to style components
npm install --save react-helmet react-router react-router-dom seamless-immutable js-cookie styled-components
Implement the login view which is based on the AuthForm component and rendered by the Login component.
The login view is placed in the main layout. Follow the steps below to implement the layout:
- Prepare Redux user actions.
- Add the Splitter component that serves as a vertical menu splitter.
- Add the Layout container implementing the layout.
- Add the LoginInfo container implementing the login/logout menu.
In the
containers/Layout.js
file, comment theuiActions
import and theonSubmitViewShown
action in themapDispatchToProps
function, and add the header's logo.
Add the layout and login view to the root component.
- Add routes. To do this, create the
./client/routes.js
file. In this file, comment all imports except theLayout
container andLogin
component, and all routes except the/login
path. - Implement the
RouteWithSubRoutes
component to provide routes.
Use a Redux store for data storing.
In the ./client/store/index.js file, add the devtools and resolve-redux middlewares and implement the logout middleware. Replace the viewModels
array with an empty array (comment out its import and usage).
Prepare the App component by adding router providers.
Now you can go to http://localhost:3000 to see the login view.
Implement the user view to show an authenticated user.
To get user data using GraphQL, import the gqlConnector
from the resolve-redux
package.
Implement the UserById container and uncomment this container import in routes and add the /user/:userId
path.
A story is news or a question a user posts. In Hacker News, stories are displayed on the following pages:
- Newest - the newest stories
- Ask - users’ questions (Ask HNs)
- Show - users’ news (Show HNs)
A story can have the following fields:
- id - a unique ID
- title - the story's title
- link - a link to the original news or external website
- text - the story's content
- createdAt - the story's creation timestamp
- createdBy - the story's author
Add the story aggregate and the createStory
command to create a story, and the storyCreated
handler to validate input data and check whether the aggregate exists.
In the original Hacker News, users can upvote and unvote stories.
This can be accomplished by adding the corresponding commands to the story aggregate:
// ./common/aggregates/validation.js
export default {
// stateIsAbsent and fieldRequired implementation
stateExists: (state, type) => {
if (!state || Object.keys(state).length === 0) {
throw new Error(`${type} does not exist`)
}
},
itemIsNotInArray: (array, item, errorMessage = 'Item is already in array') => {
if (array.includes(item)) {
throw new Error(errorMessage)
}
},
itemIsInArray: (array, item, errorMessage = 'Item is not in array') => {
if (!array.includes(item)) {
throw new Error(errorMessage)
}
}
}
Update event list by adding story event names:
// ./common/events.js
import jwt from 'jsonwebtoken'
export const STORY_CREATED = 'StoryCreated'
export const STORY_UPVOTED = 'StoryUpvoted'
export const STORY_UNVOTED = 'StoryUnvoted'
export const USER_CREATED = 'UserCreated'
// ./common/aggregates/story.js
// @flow
import {
STORY_CREATED,
STORY_UPVOTED,
STORY_UNVOTED
} from '../events'
import validate from './validation'
export default {
name: 'story',
initialState: {},
commands: {
createStory: (state: any, command: any, jwtToken: any) => {
const { id: userId, name: userName } = jwt.verify(
jwtToken,
process.env.JWT_SECRET || 'DefaultSecret'
)
validate.stateIsAbsent(state, 'Story')
const { title, link, text } = command.payload
validate.fieldRequired(command.payload, 'title')
return {
type: STORY_CREATED,
payload: { title, text, link, userId, userName }
}
},
upvoteStory: (state: any, command: any, jwtToken: any) => {
const { id: userId } = jwt.verify(
jwtToken,
process.env.JWT_SECRET || 'DefaultSecret'
)
validate.stateExists(state, 'Story')
validate.itemIsNotInArray(state.voted, userId, 'User already voted')
return { type: STORY_UPVOTED, payload: { userId } }
},
unvoteStory: (state: any, command: any, jwtToken: any) => {
const { id: userId } = jwt.verify(
jwtToken,
process.env.JWT_SECRET || 'DefaultSecret'
)
validate.stateExists(state, 'Story')
validate.itemIsInArray(state.voted, userId, 'User did not vote')
return { type: STORY_UNVOTED, payload: { userId } }
}
},
projection: {
[STORY_CREATED]: (
state,
{ timestamp, payload: { userId } }: StoryCreated
) => ({
...state,
createdAt: timestamp,
createdBy: userId,
voted: [],
comments: {}
}),
[STORY_UPVOTED]: (state, { payload: { userId } }: StoryUpvoted) => ({
...state,
voted: state.voted.concat(userId)
}),
[STORY_UNVOTED]: (state, { payload: { userId } }: StoryUnvoted) => ({
...state,
voted: state.voted.filter(curUserId => curUserId !== userId)
})
}
}
Modify the aggregates
default export:
// ./common/aggregates/index.js
import user from './user'
import story from './story'
export default [user, story]
Add all the event names to the server config:
// ./resolve.server.config.js
// import list
import * as events from './common/events'
// module hot acceptions and store initialization
const eventTypes = Object.keys(events).map(key => events[key])
export default {
// other options
initialSubscribedEvents: { types: eventTypes, ids: [] }
}
Add a collection of stories as the first read side implementation step:
// ./common/read-models/graphql/projection.js
// @flow
import {
STORY_CREATED,
STORY_UNVOTED,
STORY_UPVOTED,
USER_CREATED
} from '../../events'
export default {
Init: async store => {
await store.defineStorage('Stories', [
{ name: 'id', type: 'string', index: 'primary' },
{ name: 'type', type: 'string', index: 'secondary' },
{ name: 'title', type: 'string' },
{ name: 'text', type: 'string' },
{ name: 'link', type: 'string' },
{ name: 'commentCount', type: 'number' },
{ name: 'votes', type: 'json' },
{ name: 'createdAt', type: 'number' },
{ name: 'createdBy', type: 'string' },
{ name: 'createdByName', type: 'string' }
])
await store.defineStorage('Users', [
{ name: 'id', type: 'string', index: 'primary' },
{ name: 'name', type: 'string', index: 'secondary' },
{ name: 'createdAt', type: 'number' }
])
},
[STORY_CREATED]: async (
store,
{
aggregateId,
timestamp,
payload: { title, link, userId, userName, text }
}
) => {
const type = !link ? 'ask' : /^(Show HN)/.test(title) ? 'show' : 'story'
const story = {
id: aggregateId,
type,
title,
text,
link,
commentCount: 0,
votes: [],
createdAt: timestamp,
createdBy: userId,
createdByName: userName
}
await store.insert('Stories', story)
},
[STORY_UPVOTED]: async (
store,
{ aggregateId, payload: { userId } }
) => {
const story = await store.findOne(
'Stories',
{ id: aggregateId },
{ votes: 1 }
)
await store.update(
'Stories',
{ id: aggregateId },
{ $set: { votes: story.votes.concat(userId) } }
)
},
[STORY_UNVOTED]: async (
store,
{ aggregateId, payload: { userId } }
) => {
const story = await store.findOne(
'Stories',
{ id: aggregateId },
{ votes: 1 }
)
await store.update(
'Stories',
{ id: aggregateId },
{ $set: { votes: story.votes.filter(vote => vote !== userId) } }
)
}
// USER_CREATED implementation
}
The Hacker News application displays a list of stories without additional information. For this, support the GraphQL with GraphQL resolvers that works with read model collections.
Add the ./common/read-models/gqlSchema.js
file.
Describe the Story
type and a query used to request a list of stories - the stories
query:
// ./common/read-models/graphql/schema.js
export default `
type User {
id: ID!
name: String
createdAt: String
}
type Story {
id: ID!
type: String!
title: String!
link: String
text: String
commentCount: Int!
votes: [String]
createdAt: String!
createdBy: String!
createdByName: String!
}
type Query {
user(id: ID, name: String): User
me: User
stories(type: String, first: Int!, offset: Int): [Story]
}
`
Add the appropriate resolvers:
// ./common/read-models/graphql/resolvers.js
import jwt from 'jsonwebtoken'
export default {
// user implementation
// me implementation
stories: async (store, { type, first, offset }) => {
const skip = first || 0
const params = type ? { type } : {}
const stories = await store.find(
'Stories',
params,
null,
{ createdAt: -1 },
skip,
skip + offset
)
if (!stories) {
return []
}
return stories
}
}
Implement a component rendering a list of stories.
Install the following packages:
- url - to parse URLs
- plur - to pluralize words
- sanitizer - to sanitize story content markup
npm i --save url plur sanitizer
Add the Pagination component.
Implement stories actions.
Add the TimeAgo component.
Then add the Story container.
Create client constants.
Implement the Stories component to display stories.
Implement story-specific containers such as NewestByPage, AskByPage and ShowByPage.
In each file, delete the query
's commentCount
field.
In the client/reducers/
directory, create the optimistic reducer and add it to the root reducer export.
Add the created containers to the routes with the /
, /newest/:page?
, /show/:page?
and /ask/:page?
paths.
The Hacker News application can display a specific story with additional information. To add this feature, implement the storyDetails
view model.
Add the ./common/view-models/storyDetails.js
file:
// ./common/view-models/storyDetails.js
import Immutable from 'seamless-immutable'
import {
STORY_COMMENTED,
STORY_CREATED,
STORY_UNVOTED,
STORY_UPVOTED
} from '../events'
export default {
name: 'storyDetails',
projection: {
Init: () => Immutable({}),
[STORY_CREATED]: (
state: any,
{
aggregateId,
timestamp,
payload: { title, link, userId, text }
}: Event<StoryCreated>
) => {
const type = !link ? 'ask' : /^(Show HN)/.test(title) ? 'show' : 'story'
return Immutable({
id: aggregateId,
type,
title,
text,
link,
commentCount: 0,
comments: [],
votes: [],
createdAt: timestamp,
createdBy: userId
})
},
[STORY_UPVOTED]: (
state: any,
{ payload: { userId } }: Event<StoryUpvoted>
) => state.update('votes', votes => votes.concat(userId)),
[STORY_UNVOTED]: (
state: any,
{ payload: { userId } }: Event<StoryUnvoted>
) => state.update('votes', votes => votes.filter(id => id !== userId))
},
serializeState: (state: any) => JSON.stringify(state || {}),
deserializeState: (state: any) => Immutable(JSON.parse(state))
}
Add view models' default export:
// ./common/view-models/index.js
import storyDetails from './storyDetails'
export default [storyDetails]
Pass the view model to the server config:
// ./resolve.server.config.js
import path from 'path'
import busAdapter from 'resolve-bus-memory'
import storageAdapter from 'resolve-storage-lite'
import { localStrategy } from 'resolve-scripts-auth'
import clientConfig from './resolve.client.config'
import aggregates from './common/aggregates'
import readModels from './common/read-models'
import viewModels from './common/view-models'
import localStrategyParams from './auth/localStrategy'
import {
authenticationSecret,
cookieName,
cookieMaxAge
} from './auth/constants'
const databaseFilePath = path.join(__dirname, './storage.json')
const storageAdapterParams = process.env.IS_TEST
? {}
: { pathToFile: databaseFilePath }
export default {
entries: clientConfig,
bus: { adapter: busAdapter },
storage: {
adapter: storageAdapter,
params: storageAdapterParams
},
aggregates,
readModels,
viewModels,
jwtCookie: {
name: cookieName,
maxAge: cookieMaxAge,
httpOnly: false
},
auth: {
strategies: [localStrategy(localStrategyParams)]
}
}
Implement the StoryDetails container to display a story by ID with additional information.
ChildrenComments
is implemented later, so delete its import and usage in JSX.
Add the created container to the routes with the /storyDetails/:storyId
path.
Uncomment the viewModels
import and add it to resolveMiddleware(viewModels)
in the client/store/index.js file.
Implement the Submit container to add new stories.
Add the created container to the routes with the /submit
path.
Extend the application logic to allow users to comment. A comment is a short message written about news or question and relates to a story. Next, implement comments which reply to other comments.
A comment has the following fields:
- id - a unique ID
- parentId - the parent comment's id, or the story's id if it is a root comment
- storyId - the story's id
- text - the comment's content
- replies - a list of replies
- createdAt - the story's creation timestamp
- createdBy - the comment's author
Add a comment event to the events file:
// ./common/events.js
export const STORY_CREATED = 'StoryCreated'
export const STORY_UPVOTED = 'StoryUpvoted'
export const STORY_UNVOTED = 'StoryUnvoted'
export const STORY_COMMENTED = 'StoryCommented'
export const USER_CREATED = 'UserCreated'
Extend the validation for commands:
// ./common/aggregates/validation.js
export default {
// other validation functions
keyIsNotInObject: (object, key, errorMessage = 'Key is already in object') => {
if (object[key]) {
throw new Error(errorMessage)
}
}
}
You can use an existing story's aggregate without creating a particular aggregate for a comment, as it depends on the story. Validate all input fields and check whether an aggregate exists:
// ./common/aggregates/story.js
// @flow
import {
STORY_CREATED,
STORY_UPVOTED,
STORY_UNVOTED,
STORY_COMMENTED
} from '../events'
import validate from './validation'
export default {
name: 'story',
initialState: {},
commands: {
// the createStory, upvoteStory and unvoteStory implementation
commentStory: (state: any, command: any, jwtToken: any) => {
const { id: userId, name: userName } = jwt.verify(
jwtToken,
process.env.JWT_SECRET || 'DefaultSecret'
)
validate.stateExists(state, 'Story')
const { commentId, parentId, text } = command.payload
validate.fieldRequired(command.payload, 'parentId')
validate.fieldRequired(command.payload, 'text')
validate.keyIsNotInObject(
state.comments,
commentId,
'Comment already exists'
)
return {
type: STORY_COMMENTED,
payload: {
commentId,
parentId,
userId,
userName,
text
}
}
}
},
projection: {
// the STORY_CREATED, STORY_UPVOTED and STORY_UNVOTED implementation
[STORY_COMMENTED]: (
state,
{ timestamp, payload: { commentId, userId } }: StoryCommented
) => ({
...state,
comments: {
...state.comments,
[commentId]: {
createdAt: timestamp,
createdBy: userId
}
}
})
}
}
comment and story have a single aggregate. However, you need to provide an independent comments
collection for the GraphQL implementation. You should also update the stories
collection.
// ./common/read-model/graphql/projection.js
// @flow
import {
STORY_COMMENTED,
STORY_CREATED,
STORY_UNVOTED,
STORY_UPVOTED,
USER_CREATED
} from '../../events'
export default {
Init: async store => {
// Stories defineStorage implementation
// Users defineStorage implementation
await store.defineStorage('Comments', [
{ name: 'id', type: 'string', index: 'primary' },
{ name: 'text', type: 'string' },
{ name: 'parentId', type: 'string' },
{ name: 'comments', type: 'json' },
{ name: 'storyId', type: 'string' },
{ name: 'createdAt', type: 'number' },
{ name: 'createdBy', type: 'string' },
{ name: 'createdByName', type: 'string' }
])
},
// STORY_CREATED implementation
// STORY_UPVOTED implementation
// STORY_UNVOTED implementation
// USER_CREATED implementation
[STORY_COMMENTED]: async (
store,
{
aggregateId,
timestamp,
payload: { parentId, userId, userName, commentId, text }
}
) => {
const comment = {
id: commentId,
text,
parentId,
comments: [],
storyId: aggregateId,
createdAt: timestamp,
createdBy: userId,
createdByName: userName
}
await store.insert('Comments', comment)
await store.update(
'Stories',
{ id: aggregateId },
{ $inc: { commentCount: 1 } }
)
}
}
Extend the GraphQL schema file by adding the Comment
type and queries.
A comment contains the replies
field which is a list of comments, and provides a tree-like structure for all the included comments.
// ./common/read-models/graphql/schema.js
export default `
type User {
id: ID!
name: String
createdAt: String
}
type Story {
id: ID!
type: String!
title: String!
link: String
text: String
commentCount: Int!
votes: [String]
createdAt: String!
createdBy: String!
createdByName: String!
}
type Comment {
id: ID!
parentId: ID!
storyId: ID!
text: String!
replies: [Comment]
createdAt: String!
createdBy: String!
createdByName: String
level: Int
}
type Query {
user(id: ID, name: String): User
me: User
stories(type: String, first: Int!, offset: Int): [Story]
comments(first: Int!, offset: Int): [Comment]
comment(id: ID!): Comment
}
`
Implement comment resolvers and extend the stories resolver to get comments:
// ./common/read-models/graphql/resolvers.js
import jwt from 'jsonwebtoken'
export default {
// implemented resolvers
stories: async (store, { type, first, offset }) => {
const skip = first || 0
const params = type ? { type } : {}
const stories = await store.find(
'Stories',
params,
null,
{ createdAt: -1 },
skip,
skip + offset
)
if (!stories) {
return []
}
return stories
},
comments: async (store, { first, offset }) => {
const skip = first || 0
const comments = await store.find(
'Comments',
{},
null,
{ createdAt: -1 },
skip,
skip + offset
)
if (!comments) {
return []
}
return comments
}
}
Update the storyDetails
view model:
// ./common/view-models/storyDetails.js
import Immutable from 'seamless-immutable'
import {
STORY_COMMENTED,
STORY_CREATED,
STORY_UNVOTED,
STORY_UPVOTED
} from '../events'
export default {
name: 'storyDetails',
projection: {
Init: () => Immutable({}),
// implemented handlers
[STORY_COMMENTED]: (
state,
{
aggregateId,
timestamp,
payload: { parentId, userId, userName, commentId, text }
}
) => {
const parentIndex =
parentId === aggregateId
? -1
: state.comments.findIndex(({ id }) => id === parentId)
const level =
parentIndex === -1 ? 0 : state.comments[parentIndex].level + 1
const comment = {
id: commentId,
parentId,
level,
text,
createdAt: timestamp,
createdBy: userId,
createdByName: userName
}
const newState = state.update('commentCount', count => count + 1)
if (parentIndex === -1) {
return newState.update('comments', comments => comments.concat(comment))
} else {
return newState.update('comments', comments =>
comments
.slice(0, parentIndex + 1)
.concat(comment, comments.slice(parentIndex + 1))
)
}
}
},
serializeState: (state: any) =>
JSON.stringify(state || Immutable({})),
deserializeState: (serial: any) => Immutable(JSON.parse(serial))
}
Add the Comment component to display comment information.
Add the ReplyLink component to implement the 'reply' link.
Add the ChildrenComments component for building a comments tree.
A comment depends on a story, so you need to extend the existing StoryDetails container and add a comments tree with a text area for new comments.
Implement the CommentsByPage container to display a list of the latest comments.
Implement the CommentsById container to display the selected comment with replies.
Add the created containers to routes with the /comments/:page?
and /storyDetails/:storyId/comments/:commentId
paths.
Note that the /storyDetails/:storyId/comments/:commentId
path should be above the /storyDetails/:storyId
path.
Implement the PageNotFound component to display a message indicating that the requested page was not found.
Add the created container to the end of the the route list in the routes file.
Implement an importer in the import folder to get data from the original Hacker News website. This importer uses the website's REST API and transforms data to events.