Post follow and updates notifications V1 (#111)

* It is now possible to follow a post in order to receive updates about it
* Notifications are now sent when updates are published
* Post status changes are now tracked
* Update sidebar now shows the post status history
* Mark a comment as a post update using the comment form
* ... more ...
This commit is contained in:
Riccardo Graziosi
2022-05-28 11:03:36 +02:00
committed by GitHub
parent ce7be1b30c
commit dad382d2b1
59 changed files with 1080 additions and 71 deletions

View File

@@ -68,8 +68,30 @@ class CommentsController < ApplicationController
end
def send_notifications(comment)
if comment.post.user.notifications_enabled?
UserMailer.notify_post_owner(comment: comment).deliver_later
if comment.is_post_update # Post update
UserMailer.notify_followers_of_post_update(comment: comment).deliver_later
return
end
if comment.parent_id == nil # Reply to a post
user = comment.post.user
if comment.user.id != user.id and
user.notifications_enabled? and
comment.post.follows.exists?(user_id: user.id)
UserMailer.notify_post_owner(comment: comment).deliver_later
end
else # Reply to a comment
parent_comment = comment.parent
user = parent_comment.user
if user.notifications_enabled? and
parent_comment.user.id != comment.user.id
UserMailer.notify_comment_owner(comment: comment).deliver_later
end
end
end
end

View File

@@ -0,0 +1,53 @@
class FollowsController < ApplicationController
before_action :authenticate_user!, only: [:create, :destroy]
def index
unless user_signed_in?
render json: { }
return
end
follow = Follow.find_by(follow_params)
render json: follow
end
def create
follow = Follow.new(follow_params)
if follow.save
render json: {
id: follow.id
}, status: :created
else
render json: {
error: I18n.t('errors.follows.create', message: follow.errors.full_messages)
}, status: :unprocessable_entity
end
end
def destroy
follow = Follow.find_by(follow_params)
id = follow.id
return if follow.nil?
if follow.destroy
render json: {
id: id,
}, status: :accepted
else
render json: {
error: I18n.t('errors.follow.destroy', message: follow.errors.full_messages)
}, status: :unprocessable_entity
end
end
private
def follow_params
{
post_id: params[:post_id],
user_id: current_user.id,
}
end
end

View File

@@ -0,0 +1,16 @@
class PostStatusChangesController < ApplicationController
def index
post_status_changes = PostStatusChange
.select(
:post_status_id,
:updated_at,
'users.full_name as user_full_name',
'users.email as user_email',
)
.where(post_id: params[:post_id])
.left_outer_joins(:user)
.order(updated_at: :asc)
render json: post_status_changes
end
end

View File

@@ -28,6 +28,8 @@ class PostsController < ApplicationController
post = Post.new(post_params)
if post.save
Follow.create(post_id: post.id, user_id: current_user.id)
render json: post, status: :created
else
render json: {
@@ -57,9 +59,27 @@ class PostsController < ApplicationController
end
post.board_id = params[:post][:board_id] if params[:post].has_key?(:board_id)
post.post_status_id = params[:post][:post_status_id] if params[:post].has_key?(:post_status_id)
post_status_changed = false
if params[:post].has_key?(:post_status_id) and
params[:post][:post_status_id] != post.post_status_id
post_status_changed = true
post.post_status_id = params[:post][:post_status_id]
end
if post.save
if post_status_changed
PostStatusChange.create(
user_id: current_user.id,
post_id: post.id,
post_status_id: post.post_status_id
)
send_notifications(post)
end
render json: post, status: :no_content
else
render json: {
@@ -85,4 +105,8 @@ class PostsController < ApplicationController
.permit(:title, :description, :board_id)
.merge(user_id: current_user.id)
end
def send_notifications(post)
UserMailer.notify_followers_of_post_status_change(post: post).deliver_later
end
end

View File

@@ -11,6 +11,12 @@ interface SetCommentReplyBodyAction {
body: string;
}
export const TOGGLE_COMMENT_IS_POST_UPDATE_FLAG = 'TOGGLE_COMMENT_IS_POST_UPDATE_FLAG';
interface ToggleCommentIsPostUpdateFlag {
type: typeof TOGGLE_COMMENT_IS_POST_UPDATE_FLAG;
commentId: number;
}
export const toggleCommentReply = (commentId: number): ToggleCommentReplyAction => ({
type: TOGGLE_COMMENT_REPLY,
commentId,
@@ -22,6 +28,12 @@ export const setCommentReplyBody = (commentId: number, body: string): SetComment
body,
});
export const toggleCommentIsPostUpdateFlag = (commentId: number): ToggleCommentIsPostUpdateFlag => ({
type: TOGGLE_COMMENT_IS_POST_UPDATE_FLAG,
commentId,
});
export type HandleCommentRepliesType =
ToggleCommentReplyAction |
SetCommentReplyBodyAction;
SetCommentReplyBodyAction |
ToggleCommentIsPostUpdateFlag;

View File

@@ -52,6 +52,7 @@ export const submitComment = (
postId: number,
body: string,
parentId: number,
isPostUpdate: boolean,
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(commentSubmitStart(parentId));
@@ -64,6 +65,7 @@ export const submitComment = (
comment: {
body,
parent_id: parentId,
is_post_update: isPostUpdate,
},
}),
});

View File

@@ -0,0 +1,58 @@
import { Action } from 'redux';
import { ThunkAction } from 'redux-thunk';
import IFollowJSON from '../../interfaces/json/IFollow';
import { State } from '../../reducers/rootReducer';
export const FOLLOW_REQUEST_START = 'FOLLOW_REQUEST_START';
interface FollowRequestStartAction {
type: typeof FOLLOW_REQUEST_START;
}
export const FOLLOW_REQUEST_SUCCESS = 'FOLLOW_REQUEST_SUCCESS';
interface FollowRequestSuccessAction {
type: typeof FOLLOW_REQUEST_SUCCESS;
follow: IFollowJSON;
}
export const FOLLOW_REQUEST_FAILURE = 'FOLLOW_REQUEST_FAILURE';
interface FollowRequestFailureAction {
type: typeof FOLLOW_REQUEST_FAILURE;
error: string;
}
export type FollowRequestActionTypes =
FollowRequestStartAction |
FollowRequestSuccessAction |
FollowRequestFailureAction;
const followRequestStart = (): FollowRequestActionTypes => ({
type: FOLLOW_REQUEST_START,
});
const followRequestSuccess = (
follow: IFollowJSON,
): FollowRequestActionTypes => ({
type: FOLLOW_REQUEST_SUCCESS,
follow,
});
const followRequestFailure = (error: string): FollowRequestActionTypes => ({
type: FOLLOW_REQUEST_FAILURE,
error,
});
export const requestFollow = (
postId: number,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(followRequestStart());
try {
const response = await fetch(`/posts/${postId}/follows`);
const json = await response.json();
dispatch(followRequestSuccess(json));
} catch (e) {
dispatch(followRequestFailure(e));
}
};

View File

@@ -0,0 +1,47 @@
import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import { State } from "../../reducers/rootReducer";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import HttpStatus from "../../constants/http_status";
import IFollowJSON from "../../interfaces/json/IFollow";
export const FOLLOW_SUBMIT_SUCCESS = 'FOLLOW_SUBMIT_SUCCESS';
interface FollowSubmitSuccessAction {
type: typeof FOLLOW_SUBMIT_SUCCESS,
postId: number;
isFollow: boolean;
follow: IFollowJSON;
}
export type FollowActionTypes = FollowSubmitSuccessAction;
const followSubmitSuccess = (
postId: number,
isFollow: boolean,
follow: IFollowJSON,
): FollowSubmitSuccessAction => ({
type: FOLLOW_SUBMIT_SUCCESS,
postId,
isFollow,
follow,
});
export const submitFollow = (
postId: number,
isFollow: boolean,
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
try {
const res = await fetch(`/posts/${postId}/follows`, {
method: isFollow ? 'POST' : 'DELETE',
headers: buildRequestHeaders(authenticityToken),
});
const json = await res.json();
if (res.status === HttpStatus.Created || res.status === HttpStatus.Accepted)
dispatch(followSubmitSuccess(postId, isFollow, json));
} catch (e) {
console.log('An error occurred while following a post');
}
}

View File

@@ -0,0 +1,58 @@
import { Action } from 'redux';
import { ThunkAction } from 'redux-thunk';
import IPostStatusChangeJSON from '../../interfaces/json/IPostStatusChange';
import { State } from '../../reducers/rootReducer';
export const POST_STATUS_CHANGES_REQUEST_START = 'POST_STATUS_CHANGES_REQUEST_START';
interface PostStatusChangesRequestStartAction {
type: typeof POST_STATUS_CHANGES_REQUEST_START;
}
export const POST_STATUS_CHANGES_REQUEST_SUCCESS = 'POST_STATUS_CHANGES_REQUEST_SUCCESS';
interface PostStatusChangesRequestSuccessAction {
type: typeof POST_STATUS_CHANGES_REQUEST_SUCCESS;
postStatusChanges: Array<IPostStatusChangeJSON>;
}
export const POST_STATUS_CHANGES_REQUEST_FAILURE = 'POST_STATUS_CHANGES_REQUEST_FAILURE';
interface PostStatusChangesRequestFailureAction {
type: typeof POST_STATUS_CHANGES_REQUEST_FAILURE;
error: string;
}
export type PostStatusChangesRequestActionTypes =
PostStatusChangesRequestStartAction |
PostStatusChangesRequestSuccessAction |
PostStatusChangesRequestFailureAction;
const postStatusChangesRequestStart = (): PostStatusChangesRequestActionTypes => ({
type: POST_STATUS_CHANGES_REQUEST_START,
});
const postStatusChangesRequestSuccess = (
postStatusChanges: Array<IPostStatusChangeJSON>,
): PostStatusChangesRequestActionTypes => ({
type: POST_STATUS_CHANGES_REQUEST_SUCCESS,
postStatusChanges,
});
const postStatusChangesRequestFailure = (error: string): PostStatusChangesRequestActionTypes => ({
type: POST_STATUS_CHANGES_REQUEST_FAILURE,
error,
});
export const requestPostStatusChanges = (
postId: number,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(postStatusChangesRequestStart());
try {
const response = await fetch(`/posts/${postId}/post_status_changes`);
const json = await response.json();
dispatch(postStatusChangesRequestSuccess(json));
} catch (e) {
dispatch(postStatusChangesRequestFailure(e));
}
};

View File

@@ -0,0 +1,14 @@
import IPostStatusChange from "../../interfaces/IPostStatusChange";
export const POST_STATUS_CHANGE_SUBMITTED = 'POST_STATUS_CHANGE_SUBMITTED';
export interface PostStatusChangeSubmitted {
type: typeof POST_STATUS_CHANGE_SUBMITTED;
postStatusChange: IPostStatusChange;
}
export const postStatusChangeSubmitted = (
postStatusChange: IPostStatusChange
): PostStatusChangeSubmitted => ({
type: POST_STATUS_CHANGE_SUBMITTED,
postStatusChange,
});

View File

@@ -7,7 +7,7 @@ import { MutedText } from '../shared/CustomTexts';
import { ReplyFormState } from '../../reducers/replyFormReducer';
import friendlyDate from '../../helpers/friendlyDate';
import friendlyDate from '../../helpers/datetime';
interface Props {
id: number;
@@ -21,7 +21,7 @@ interface Props {
handleToggleCommentReply(): void;
handleCommentReplyBodyChange(e: React.FormEvent): void;
handleToggleIsCommentUpdate(commentId: number, currentIsPostUpdate: boolean): void;
handleSubmitComment(body: string, parentId: number): void;
handleSubmitComment(body: string, parentId: number, isPostUpdate: boolean): void;
isLoggedIn: boolean;
isPowerUser: boolean;
@@ -48,7 +48,7 @@ const Comment = ({
}: Props) => (
<div className="comment">
<div className="commentHeader">
<Gravatar email={userEmail} size={24} className="gravatar" />
<Gravatar email={userEmail} size={28} className="gravatar" />
<span className="commentAuthor">{userFullName}</span>
{ isPostUpdate ? <span className="postUpdateBadge">Post update</span> : null }
</div>
@@ -89,12 +89,15 @@ const Comment = ({
<NewComment
body={replyForm.body}
parentId={id}
postUpdateFlagValue={replyForm.isPostUpdate}
isSubmitting={replyForm.isSubmitting}
error={replyForm.error}
handleChange={handleCommentReplyBodyChange}
handlePostUpdateFlag={() => null}
handleSubmit={handleSubmitComment}
isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser}
userEmail={currentUserEmail}
/>
:

View File

@@ -14,7 +14,7 @@ interface Props {
toggleCommentReply(commentId: number): void;
setCommentReplyBody(commentId: number, body: string): void;
handleToggleIsCommentUpdate(commentId: number, currentIsPostUpdate: boolean): void;
handleSubmitComment(body: string, parentId: number): void;
handleSubmitComment(body: string, parentId: number, isPostUpdate: boolean): void;
isLoggedIn: boolean;
isPowerUser: boolean;

View File

@@ -23,6 +23,7 @@ interface Props {
requestComments(postId: number, page?: number): void;
toggleCommentReply(commentId: number): void;
setCommentReplyBody(commentId: number, body: string): void;
toggleCommentIsPostUpdateFlag(): void;
toggleCommentIsPostUpdate(
postId: number,
commentId: number,
@@ -33,6 +34,7 @@ interface Props {
postId: number,
body: string,
parentId: number,
isPostUpdate: boolean,
authenticityToken: string,
): void;
}
@@ -51,11 +53,12 @@ class CommentsP extends React.Component<Props> {
);
}
_handleSubmitComment = (body: string, parentId: number) => {
_handleSubmitComment = (body: string, parentId: number, isPostUpdate: boolean) => {
this.props.submitComment(
this.props.postId,
body,
parentId,
isPostUpdate,
this.props.authenticityToken,
);
}
@@ -73,6 +76,7 @@ class CommentsP extends React.Component<Props> {
toggleCommentReply,
setCommentReplyBody,
toggleCommentIsPostUpdateFlag,
} = this.props;
const postReply = replyForms.find(replyForm => replyForm.commentId === null);
@@ -82,6 +86,7 @@ class CommentsP extends React.Component<Props> {
<NewComment
body={postReply && postReply.body}
parentId={null}
postUpdateFlagValue={postReply && postReply.isPostUpdate}
isSubmitting={postReply && postReply.isSubmitting}
error={postReply && postReply.error}
handleChange={
@@ -89,9 +94,11 @@ class CommentsP extends React.Component<Props> {
setCommentReplyBody(null, (e.target as HTMLTextAreaElement).value)
)
}
handlePostUpdateFlag={toggleCommentIsPostUpdateFlag}
handleSubmit={this._handleSubmitComment}
isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser}
userEmail={userEmail}
/>
@@ -99,7 +106,7 @@ class CommentsP extends React.Component<Props> {
{ error ? <DangerText>{error}</DangerText> : null }
<div className="commentsTitle">
activity &bull; {comments.length} comments
activity &bull; {comments.length} comment{comments.length === 1 ? '' : 's'}
</div>
<CommentList

View File

@@ -1,6 +1,8 @@
import * as React from 'react';
import Gravatar from 'react-gravatar';
import NewCommentUpdateSection from './NewCommentUpdateSection';
import Button from '../shared/Button';
import Spinner from '../shared/Spinner';
import { DangerText } from '../shared/CustomTexts';
@@ -8,24 +10,34 @@ import { DangerText } from '../shared/CustomTexts';
interface Props {
body: string;
parentId: number;
postUpdateFlagValue: boolean;
isSubmitting: boolean;
error: string;
handleChange(e: React.FormEvent): void;
handleSubmit(body: string, parentId: number): void;
handlePostUpdateFlag(): void;
handleSubmit(
body: string,
parentId: number,
isPostUpdate: boolean
): void;
isLoggedIn: boolean;
isPowerUser: boolean;
userEmail: string;
}
const NewComment = ({
body,
parentId,
postUpdateFlagValue,
isSubmitting,
error,
handleChange,
handlePostUpdateFlag,
handleSubmit,
isLoggedIn,
isPowerUser,
userEmail,
}: Props) => (
<React.Fragment>
@@ -33,18 +45,29 @@ const NewComment = ({
{
isLoggedIn ?
<React.Fragment>
<Gravatar email={userEmail} size={36} className="currentUserAvatar" />
<textarea
value={body}
onChange={handleChange}
placeholder="Leave a comment"
className="newCommentBody"
/>
<Button
onClick={() => handleSubmit(body, parentId)}
className="submitCommentButton">
{ isSubmitting ? <Spinner color="light" /> : 'Submit' }
</Button>
<div className="commentBodyForm">
<Gravatar email={userEmail} size={48} className="currentUserAvatar" />
<textarea
value={body}
onChange={handleChange}
placeholder="Leave a comment"
className="newCommentBody"
/>
<Button
onClick={() => handleSubmit(body, parentId, postUpdateFlagValue)}
className="submitCommentButton">
{ isSubmitting ? <Spinner color="light" /> : 'Submit' }
</Button>
</div>
{
isPowerUser && parentId == null ?
<NewCommentUpdateSection
postUpdateFlagValue={postUpdateFlagValue}
handlePostUpdateFlag={handlePostUpdateFlag}
/>
:
null
}
</React.Fragment>
:
<a href="/users/sign_in" className="loginInfo">You need to log in to post comments.</a>

View File

@@ -0,0 +1,33 @@
import * as React from 'react';
import { MutedText } from '../shared/CustomTexts';
interface Props {
postUpdateFlagValue: boolean;
handlePostUpdateFlag(): void;
}
const NewCommentUpdateSection = ({
postUpdateFlagValue,
handlePostUpdateFlag,
}: Props) => (
<div className="commentIsUpdateForm">
<div>
<input
id="isPostUpdateFlag"
type="checkbox"
onChange={handlePostUpdateFlag}
checked={postUpdateFlagValue || false}
/>
&nbsp;
<label htmlFor="isPostUpdateFlag">Mark as post update</label>
</div>
{
postUpdateFlagValue ?
<MutedText>Users that follow this post will be notified</MutedText>
:
null
}
</div>
);
export default NewCommentUpdateSection;

View File

@@ -0,0 +1,33 @@
import * as React from 'react';
import Button from '../shared/Button';
import { BoxTitleText, SmallMutedText } from '../shared/CustomTexts';
interface Props {
followed: boolean;
submitFollow(): void;
isLoggedIn: boolean;
}
const ActionBox = ({followed, submitFollow, isLoggedIn}: Props) => (
<div className="actionBoxContainer">
<div className="actionBoxFollow">
<BoxTitleText>Actions</BoxTitleText>
<br />
<Button onClick={isLoggedIn ? submitFollow : () => location.href = '/users/sign_in'} outline>
{ followed ? 'Unfollow post' : 'Follow post' }
</Button>
<br />
<SmallMutedText>
{ followed ?
'you\'re receiving notifications about new updates on this post'
:
'you won\'t receive notifications about this post'
}
</SmallMutedText>
</div>
</div>
);
export default ActionBox;

View File

@@ -4,7 +4,9 @@ import IPost from '../../interfaces/IPost';
import IPostStatus from '../../interfaces/IPostStatus';
import IBoard from '../../interfaces/IBoard';
import PostUpdateList from './PostUpdateList';
import LikeList from './LikeList';
import ActionBox from './ActionBox';
import LikeButton from '../../containers/LikeButton';
import PostBoardSelect from './PostBoardSelect';
import PostStatusSelect from './PostStatusSelect';
@@ -13,25 +15,31 @@ import PostStatusLabel from '../shared/PostStatusLabel';
import Comments from '../../containers/Comments';
import { MutedText } from '../shared/CustomTexts';
import friendlyDate from '../../helpers/friendlyDate';
import { LikesState } from '../../reducers/likesReducer';
import { CommentsState } from '../../reducers/commentsReducer';
import PostUpdateList from './PostUpdateList';
import { PostStatusChangesState } from '../../reducers/postStatusChangesReducer';
import friendlyDate, { fromRailsStringToJavascriptDate } from '../../helpers/datetime';
interface Props {
postId: number;
post: IPost;
likes: LikesState;
followed: boolean;
comments: CommentsState;
postStatusChanges: PostStatusChangesState;
boards: Array<IBoard>;
postStatuses: Array<IPostStatus>;
isLoggedIn: boolean;
isPowerUser: boolean;
userFullName: string;
userEmail: string;
authenticityToken: string;
requestPost(postId: number): void;
requestLikes(postId: number): void;
requestFollow(postId: number): void;
requestPostStatusChanges(postId: number): void;
changePostBoard(
postId: number,
newBoardId: number,
@@ -40,38 +48,62 @@ interface Props {
changePostStatus(
postId: number,
newPostStatusId: number,
userFullName: string,
userEmail: string,
authenticityToken: string,
): void;
submitFollow(
postId: number,
isFollow: boolean,
authenticityToken: string,
): void;
}
class PostP extends React.Component<Props> {
componentDidMount() {
this.props.requestPost(this.props.postId);
this.props.requestLikes(this.props.postId);
const {postId} = this.props;
this.props.requestPost(postId);
this.props.requestLikes(postId);
this.props.requestFollow(postId);
this.props.requestPostStatusChanges(postId);
}
render() {
const {
post,
likes,
followed,
comments,
postStatusChanges,
boards,
postStatuses,
isLoggedIn,
isPowerUser,
userFullName,
userEmail,
authenticityToken,
changePostBoard,
changePostStatus,
submitFollow,
} = this.props;
const postUpdates = [
...comments.items.filter(comment => comment.isPostUpdate === true),
...postStatusChanges.items,
].sort(
(a, b) =>
fromRailsStringToJavascriptDate(a.updatedAt) < fromRailsStringToJavascriptDate(b.updatedAt) ? 1 : -1
);
return (
<div className="pageContainer">
<div className="sidebar">
<PostUpdateList
postUpdates={comments.items.filter(comment => comment.isPostUpdate === true)}
postUpdates={postUpdates}
postStatuses={postStatuses}
areLoading={comments.areLoading}
error={comments.error}
/>
@@ -81,6 +113,13 @@ class PostP extends React.Component<Props> {
areLoading={likes.areLoading}
error={likes.error}
/>
<ActionBox
followed={followed}
submitFollow={() => submitFollow(post.id, !followed, authenticityToken)}
isLoggedIn={isLoggedIn}
/>
</div>
<div className="postAndCommentsContainer">
@@ -113,7 +152,8 @@ class PostP extends React.Component<Props> {
postStatuses={postStatuses}
selectedPostStatusId={post.postStatusId}
handleChange={
newPostStatusId => changePostStatus(post.id, newPostStatusId, authenticityToken)
newPostStatusId =>
changePostStatus(post.id, newPostStatusId, userFullName, userEmail, authenticityToken)
}
/>
</div>

View File

@@ -5,17 +5,22 @@ import { BoxTitleText, DangerText, CenteredMutedText, MutedText } from '../share
import Spinner from '../shared/Spinner';
import IComment from '../../interfaces/IComment';
import IPostStatusChange from '../../interfaces/IPostStatusChange';
import IPostStatus from '../../interfaces/IPostStatus';
import friendlyDate from '../../helpers/friendlyDate';
import friendlyDate from '../../helpers/datetime';
import PostStatusLabel from '../shared/PostStatusLabel';
interface Props {
postUpdates: Array<IComment>;
postUpdates: Array<IComment | IPostStatusChange>;
postStatuses: Array<IPostStatus>
areLoading: boolean;
error: string;
}
const PostUpdateList = ({
postUpdates,
postStatuses,
areLoading,
error,
}: Props) => (
@@ -33,7 +38,18 @@ const PostUpdateList = ({
<span>{postUpdate.userFullName}</span>
</div>
<p className="postUpdateListItemBody">{postUpdate.body}</p>
<p className="postUpdateListItemBody">
{ 'body' in postUpdate ?
postUpdate.body
:
<React.Fragment>
<i>changed status to</i>&nbsp;
<PostStatusLabel
{...postStatuses.find(postStatus => postStatus.id === postUpdate.postStatusId)}
/>
</React.Fragment>
}
</p>
<MutedText>{friendlyDate(postUpdate.updatedAt)}</MutedText>
</div>

View File

@@ -17,6 +17,7 @@ interface Props {
postStatuses: Array<IPostStatus>;
isLoggedIn: boolean;
isPowerUser: boolean;
userFullName: string;
userEmail: string;
authenticityToken: string;
}
@@ -37,6 +38,7 @@ class PostRoot extends React.Component<Props> {
postStatuses,
isLoggedIn,
isPowerUser,
userFullName,
userEmail,
authenticityToken
} = this.props;
@@ -50,6 +52,7 @@ class PostRoot extends React.Component<Props> {
isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser}
userFullName={userFullName}
userEmail={userEmail}
authenticityToken={authenticityToken}
/>

View File

@@ -25,6 +25,10 @@ export const CenteredMutedText = ({ children }: Props) => (
<p className="centeredText"><span className="mutedText">{children}</span></p>
);
export const SmallMutedText = ({ children }: Props) => (
<p className="smallMutedText">{children}</p>
);
export const UppercaseText = ({ children }: Props) => (
<span className="uppercaseText">{children}</span>
);

View File

@@ -9,8 +9,8 @@ const PostStatusLabel = ({
name,
color,
}: Props) => (
<span className="badge" style={{backgroundColor: color, color: 'white'}}>
{name?.toUpperCase()}
<span className="badge" style={{backgroundColor: color || 'black', color: 'white'}}>
{(name || 'no status').toUpperCase()}
</span>
);

View File

@@ -4,6 +4,7 @@ import { requestComments } from '../actions/Comment/requestComments';
import {
toggleCommentReply,
setCommentReplyBody,
toggleCommentIsPostUpdateFlag,
} from '../actions/Comment/handleCommentReplies';
import { toggleCommentIsUpdate } from '../actions/Comment/updateComment';
import { submitComment } from '../actions/Comment/submitComment';
@@ -32,6 +33,10 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(setCommentReplyBody(commentId, body));
},
toggleCommentIsPostUpdateFlag() {
dispatch(toggleCommentIsPostUpdateFlag(null));
},
toggleCommentIsPostUpdate(
postId: number,
commentId: number,
@@ -45,9 +50,10 @@ const mapDispatchToProps = (dispatch) => ({
postId: number,
body: string,
parentId: number,
isPostUpdate: boolean,
authenticityToken: string,
) {
dispatch(submitComment(postId, body, parentId, authenticityToken));
dispatch(submitComment(postId, body, parentId, isPostUpdate, authenticityToken));
},
});

View File

@@ -4,15 +4,23 @@ import { requestPost } from '../actions/Post/requestPost';
import { requestLikes } from '../actions/Like/requestLikes';
import { changePostBoard } from '../actions/Post/changePostBoard';
import { changePostStatus } from '../actions/Post/changePostStatus';
import { submitFollow } from '../actions/Follow/submitFollow';
import { requestFollow } from '../actions/Follow/requestFollow';
import { requestPostStatusChanges } from '../actions/PostStatusChange/requestPostStatusChanges';
import { postStatusChangeSubmitted } from '../actions/PostStatusChange/submittedPostStatusChange';
import { State } from '../reducers/rootReducer';
import PostP from '../components/Post/PostP';
import { fromJavascriptDateToRailsString } from '../helpers/datetime';
const mapStateToProps = (state: State) => ({
post: state.currentPost.item,
likes: state.currentPost.likes,
followed: state.currentPost.followed,
comments: state.currentPost.comments,
postStatusChanges: state.currentPost.postStatusChanges,
});
const mapDispatchToProps = (dispatch) => ({
@@ -24,14 +32,41 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(requestLikes(postId));
},
requestFollow(postId: number) {
dispatch(requestFollow(postId));
},
requestPostStatusChanges(postId: number) {
dispatch(requestPostStatusChanges(postId));
},
changePostBoard(postId: number, newBoardId: number, authenticityToken: string) {
dispatch(changePostBoard(postId, newBoardId, authenticityToken));
},
changePostStatus(postId: number, newPostStatusId: number, authenticityToken: string) {
changePostStatus(
postId: number,
newPostStatusId: number,
userFullName: string,
userEmail: string,
authenticityToken: string
) {
if (isNaN(newPostStatusId)) newPostStatusId = null;
dispatch(changePostStatus(postId, newPostStatusId, authenticityToken));
dispatch(changePostStatus(postId, newPostStatusId, authenticityToken)).then(res => {
if (res && res.status !== 204) return;
dispatch(postStatusChangeSubmitted({
postStatusId: newPostStatusId,
userFullName,
userEmail,
updatedAt: fromJavascriptDateToRailsString(new Date()),
}));
});
},
submitFollow(postId: number, isFollow: boolean, authenticityToken: string) {
dispatch(submitFollow(postId, isFollow, authenticityToken));
},
});

View File

@@ -1,4 +1,4 @@
const friendlyDate = date => {
export const friendlyDate = date => {
var now = new Date();
var timeStamp = fromRailsStringToJavascriptDate(date);
@@ -7,13 +7,13 @@ const friendlyDate = date => {
if (secondsPast < 60) {
return 'just now';
} else if (secondsPast < 3600) {
let minutesPast = parseInt(secondsPast / 60);
let minutesPast = Math.round(secondsPast / 60);
return minutesPast + ' ' + (minutesPast === 1 ? 'minute' : 'minutes') + ' ago';
} else if (secondsPast <= 86400) {
let hoursPast = parseInt(secondsPast / 3600);
let hoursPast = Math.round(secondsPast / 3600);
return hoursPast + ' ' + (hoursPast === 1 ? 'hour' : 'hours') + ' ago';
} else {
let daysPast = parseInt(secondsPast / 86400);
let daysPast = Math.round(secondsPast / 86400);
return daysPast + ' ' + (daysPast === 1 ? 'day' : 'days') + ' ago';
}
}
@@ -24,9 +24,13 @@ export default friendlyDate;
Converts the default Rails datetime string
format to a JavaScript Date object.
*/
const fromRailsStringToJavascriptDate = date => {
export const fromRailsStringToJavascriptDate = date => {
let dateOnly = date.slice(0, 10);
let timeOnly = date.slice(11, 19);
return new Date(`${dateOnly}T${timeOnly}Z`);
}
export const fromJavascriptDateToRailsString = (date: Date) => {
return date.toJSON();
}

View File

@@ -0,0 +1,8 @@
interface IPostStatusChange {
postStatusId: number;
userFullName: string;
userEmail: string;
updatedAt: string;
}
export default IPostStatusChange;

View File

@@ -0,0 +1,7 @@
interface IFollowJSON {
id: number;
user_id: number;
post_id: number;
}
export default IFollowJSON;

View File

@@ -0,0 +1,8 @@
interface IPostStatusChangeJSON {
post_status_id: number;
user_full_name: string;
user_email: string;
updated_at: string;
}
export default IPostStatusChangeJSON;

View File

@@ -11,6 +11,7 @@ import {
HandleCommentRepliesType,
TOGGLE_COMMENT_REPLY,
SET_COMMENT_REPLY_BODY,
TOGGLE_COMMENT_IS_POST_UPDATE_FLAG,
} from '../actions/Comment/handleCommentReplies';
import {
@@ -82,6 +83,7 @@ const commentsReducer = (
case TOGGLE_COMMENT_REPLY:
case SET_COMMENT_REPLY_BODY:
case TOGGLE_COMMENT_IS_POST_UPDATE_FLAG:
return {
...state,
replyForms: replyFormsReducer(state.replyForms, action),

View File

@@ -38,6 +38,7 @@ import {
HandleCommentRepliesType,
TOGGLE_COMMENT_REPLY,
SET_COMMENT_REPLY_BODY,
TOGGLE_COMMENT_IS_POST_UPDATE_FLAG,
} from '../actions/Comment/handleCommentReplies';
import {
@@ -52,21 +53,40 @@ import {
TOGGLE_COMMENT_IS_UPDATE_SUCCESS,
} from '../actions/Comment/updateComment';
import { FollowActionTypes, FOLLOW_SUBMIT_SUCCESS } from '../actions/Follow/submitFollow';
import { FollowRequestActionTypes, FOLLOW_REQUEST_SUCCESS } from '../actions/Follow/requestFollow';
import {
PostStatusChangesRequestActionTypes,
POST_STATUS_CHANGES_REQUEST_START,
POST_STATUS_CHANGES_REQUEST_SUCCESS,
POST_STATUS_CHANGES_REQUEST_FAILURE,
} from '../actions/PostStatusChange/requestPostStatusChanges';
import {
PostStatusChangeSubmitted,
POST_STATUS_CHANGE_SUBMITTED
} from '../actions/PostStatusChange/submittedPostStatusChange';
import postReducer from './postReducer';
import likesReducer from './likesReducer';
import commentsReducer from './commentsReducer';
import { LikesState } from './likesReducer';
import { CommentsState } from './commentsReducer';
import postStatusChangesReducer, { PostStatusChangesState } from './postStatusChangesReducer';
import IPost from '../interfaces/IPost';
interface CurrentPostState {
item: IPost;
isLoading: boolean;
error: string;
likes: LikesState;
followed: boolean;
comments: CommentsState;
postStatusChanges: PostStatusChangesState,
}
const initialState: CurrentPostState = {
@@ -74,7 +94,9 @@ const initialState: CurrentPostState = {
isLoading: false,
error: '',
likes: likesReducer(undefined, {} as LikesRequestActionTypes),
followed: false,
comments: commentsReducer(undefined, {} as CommentsRequestActionTypes),
postStatusChanges: postStatusChangesReducer(undefined, {} as PostStatusChangesRequestActionTypes),
};
const currentPostReducer = (
@@ -88,7 +110,11 @@ const currentPostReducer = (
CommentsRequestActionTypes |
HandleCommentRepliesType |
CommentSubmitActionTypes |
ToggleIsUpdateSuccessAction
ToggleIsUpdateSuccessAction |
FollowActionTypes |
FollowRequestActionTypes |
PostStatusChangesRequestActionTypes |
PostStatusChangeSubmitted
): CurrentPostState => {
switch (action.type) {
case POST_REQUEST_START:
@@ -137,11 +163,33 @@ const currentPostReducer = (
case COMMENT_SUBMIT_SUCCESS:
case COMMENT_SUBMIT_FAILURE:
case TOGGLE_COMMENT_IS_UPDATE_SUCCESS:
case TOGGLE_COMMENT_IS_POST_UPDATE_FLAG:
return {
...state,
comments: commentsReducer(state.comments, action),
};
case FOLLOW_REQUEST_SUCCESS:
return {
...state,
followed: action.follow.user_id ? true : false,
};
case FOLLOW_SUBMIT_SUCCESS:
return {
...state,
followed: action.isFollow,
};
case POST_STATUS_CHANGES_REQUEST_START:
case POST_STATUS_CHANGES_REQUEST_SUCCESS:
case POST_STATUS_CHANGES_REQUEST_FAILURE:
case POST_STATUS_CHANGE_SUBMITTED:
return {
...state,
postStatusChanges: postStatusChangesReducer(state.postStatusChanges, action),
};
default:
return state;
}

View File

@@ -0,0 +1,71 @@
import {
PostStatusChangesRequestActionTypes,
POST_STATUS_CHANGES_REQUEST_START,
POST_STATUS_CHANGES_REQUEST_SUCCESS,
POST_STATUS_CHANGES_REQUEST_FAILURE,
} from "../actions/PostStatusChange/requestPostStatusChanges";
import {
PostStatusChangeSubmitted,
POST_STATUS_CHANGE_SUBMITTED
} from '../actions/PostStatusChange/submittedPostStatusChange';
import IPostStatusChange from "../interfaces/IPostStatusChange";
export interface PostStatusChangesState {
items: Array<IPostStatusChange>;
areLoading: boolean;
error: string;
}
const initialState: PostStatusChangesState = {
items: [],
areLoading: false,
error: '',
};
const postStatusChangesReducer = (
state = initialState,
action:
PostStatusChangesRequestActionTypes |
PostStatusChangeSubmitted
) => {
switch (action.type) {
case POST_STATUS_CHANGES_REQUEST_START:
return {
...state,
areLoading: true,
};
case POST_STATUS_CHANGES_REQUEST_SUCCESS:
return {
...state,
items: action.postStatusChanges.map(postStatusChange => ({
postStatusId: postStatusChange.post_status_id,
userFullName: postStatusChange.user_full_name,
userEmail: postStatusChange.user_email,
updatedAt: postStatusChange.updated_at,
})),
areLoading: false,
error: '',
};
case POST_STATUS_CHANGES_REQUEST_FAILURE:
return {
...state,
areLoading: false,
error: action.error,
};
case POST_STATUS_CHANGE_SUBMITTED:
return {
...state,
items: [action.postStatusChange, ...state.items],
};
default:
return state;
}
}
export default postStatusChangesReducer;

View File

@@ -7,6 +7,7 @@ import {
HandleCommentRepliesType,
TOGGLE_COMMENT_REPLY,
SET_COMMENT_REPLY_BODY,
TOGGLE_COMMENT_IS_POST_UPDATE_FLAG,
} from '../actions/Comment/handleCommentReplies';
import {
@@ -20,6 +21,7 @@ export interface ReplyFormState {
commentId: number;
isOpen: boolean;
body: string;
isPostUpdate: boolean;
isSubmitting: boolean;
error: string;
}
@@ -28,6 +30,7 @@ const initialState: ReplyFormState = {
commentId: undefined,
isOpen: false,
body: '',
isPostUpdate: false,
isSubmitting: false,
error: '',
}
@@ -58,6 +61,12 @@ const replyFormReducer = (
body: action.body,
};
case TOGGLE_COMMENT_IS_POST_UPDATE_FLAG:
return {
...state,
isPostUpdate: !state.isPostUpdate,
};
case COMMENT_SUBMIT_START:
return {
...state,
@@ -69,6 +78,7 @@ const replyFormReducer = (
...state,
isOpen: false,
body: '',
isPostUpdate: false,
isSubmitting: false,
error: '',
};

View File

@@ -9,6 +9,7 @@ import {
HandleCommentRepliesType,
TOGGLE_COMMENT_REPLY,
SET_COMMENT_REPLY_BODY,
TOGGLE_COMMENT_IS_POST_UPDATE_FLAG,
} from '../actions/Comment/handleCommentReplies';
import {
@@ -42,6 +43,7 @@ const ReplyFormsReducer = (
case TOGGLE_COMMENT_REPLY:
case SET_COMMENT_REPLY_BODY:
case TOGGLE_COMMENT_IS_POST_UPDATE_FLAG:
return (
state.map(
replyForm => (

View File

@@ -1,9 +1,25 @@
.commentsContainer {
@extend .my-3;
.newCommentForm {
@extend
.d-flex,
.flex-column,
.my-3;
.commentBodyForm {
@extend .d-flex;
}
.commentIsUpdateForm {
@extend
.d-flex,
.justify-content-between,
.mt-3;
margin-left: 48px;
}
.currentUserAvatar {
@extend
.gravatar,

View File

@@ -17,7 +17,9 @@
@extend .sidebarCard;
.postUpdateList {
@extend .w-100;
@extend
.scroll-shadows,
.w-100;
max-height: 250px;
overflow-y: scroll;
@@ -26,8 +28,16 @@
@extend
.d-flex,
.flex-column,
.p-2,
.my-1;
.p-2;
&:after { // displays the little centered border under each post update
content: "";
display: block;
margin: 0 auto;
width: 25%;
padding-top: 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.postUpdateListItemHeader {
@extend .d-flex;
@@ -42,7 +52,7 @@
}
.postUpdateListItemBody {
@extend .m-0;
@extend .my-1;
font-size: 15px;
}
@@ -54,7 +64,9 @@
@extend .sidebarCard;
.likeList {
@extend .w-100;
@extend
.scroll-shadows,
.w-100;
max-height: 170px;
overflow-y: scroll;
@@ -69,7 +81,19 @@
}
}
}
}
}
.actionBoxContainer {
@extend
.sidebarCard,
.text-center;
.btn {
@extend
.mt-3,
.mb-1;
}
}
}
.postAndCommentsContainer {

View File

@@ -72,7 +72,7 @@ a {
.mb-3,
.p-2;
width: 250px;
width: 280px;
margin-right: 16px;
}
}

View File

@@ -27,6 +27,15 @@
color: $muted-text-color;
}
.smallMutedText {
@extend
.mutedText,
.m-0;
font-size: smaller;
line-height: 95%;
}
.uppercaseText {
@extend
.text-secondary,

View File

@@ -4,7 +4,7 @@
*/
.container {
max-width: 920px;
max-width: 960px;
}
.turbolinks-progress-bar {
@@ -34,4 +34,41 @@
color: #fff;
background-color: #343a40;
border-color: #343a40;
}
// Credits: https://codepen.io/chriscoyier/pen/YzXBYvL
.scroll-shadows {
max-height: 200px;
overflow: auto;
background:
/* Shadow Cover TOP */
linear-gradient(
white 30%,
rgba(255, 255, 255, 0)
) center top,
/* Shadow Cover BOTTOM */
linear-gradient(
rgba(255, 255, 255, 0),
white 70%
) center bottom,
/* Shadow TOP */
radial-gradient(
farthest-side at 50% 0,
rgba(0, 0, 0, 0.2),
rgba(0, 0, 0, 0)
) center top,
/* Shadow BOTTOM */
radial-gradient(
farthest-side at 50% 100%,
rgba(0, 0, 0, 0.2),
rgba(0, 0, 0, 0)
) center bottom;
background-repeat: no-repeat;
background-size: 100% 40px, 100% 40px, 100% 10px, 100% 10px;
background-attachment: local, local, scroll, scroll;
}

View File

@@ -1,4 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: 'from@example.com'
default from: "notifications@example.com"
layout 'mailer'
end

View File

@@ -1,10 +1,45 @@
class UserMailer < ApplicationMailer
default from: "notifications@example.com"
def notify_post_owner(comment:)
@comment = comment
@user = comment.post.user
mail(to: @user.email, subject: "[#{ENV.fetch('APP_NAME')}] - New comment on #{comment.post.title}")
mail(
to: @user.email,
subject: "[#{app_name}] New comment on #{comment.post.title}"
)
end
def notify_comment_owner(comment:)
@comment = comment
@user = comment.parent.user
mail(
to: @user.email,
subject: "[#{app_name}] New reply on your comment from #{comment.post.title}"
)
end
def notify_followers_of_post_update(comment:)
@comment = comment
mail(
to: comment.post.followers.pluck(:email),
subject: "[#{app_name}] New update on #{comment.post.title}"
)
end
def notify_followers_of_post_status_change(post:)
@post = post
mail(
to: post.followers.pluck(:email),
subject: "[#{app_name}] Status change on post #{post.title}"
)
end
private
def app_name
ENV.fetch('APP_NAME')
end
end

6
app/models/follow.rb Normal file
View File

@@ -0,0 +1,6 @@
class Follow < ApplicationRecord
belongs_to :user
belongs_to :post
validates :user_id, uniqueness: { scope: :post_id }
end

View File

@@ -2,8 +2,12 @@ class Post < ApplicationRecord
belongs_to :board
belongs_to :user
belongs_to :post_status, optional: true
has_many :likes, dependent: :destroy
has_many :follows, dependent: :destroy
has_many :followers, through: :follows, source: :user
has_many :comments, dependent: :destroy
has_many :post_status_changes, dependent: :destroy
validates :title, presence: true, length: { in: 4..64 }

View File

@@ -0,0 +1,5 @@
class PostStatusChange < ApplicationRecord
belongs_to :user
belongs_to :post
belongs_to :post_status, optional: true
end

View File

@@ -17,7 +17,7 @@
</div>
<div class="form-group">
<%= f.label :notifications_enabled %> <br />
<%= f.label :notifications_enabled %>&nbsp;
<%= f.check_box :notifications_enabled, style: "transform: scale(1.5)" %>
<small id="notificationsHelp" class="form-text text-muted">
if disabled, you won't receive any notification

View File

@@ -7,6 +7,7 @@
postStatuses: @post_statuses,
isLoggedIn: user_signed_in?,
isPowerUser: user_signed_in? ? current_user.power_user? : false,
userFullName: user_signed_in? ? current_user.full_name : nil,
userEmail: user_signed_in? ? current_user.email : nil,
authenticityToken: form_authenticity_token,
}

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<meta content='text/html; charset=utf-8' http-equiv='content-type' />
</head>
<body>
<h1>Hello, <%= @user.full_name %></h1>
<p>
There is a new reply by <b><%= @comment.user.full_name %></b> on your comment from post <b><%= @comment.post.title %></b>:
</p>
<p>
<i><%= @comment.body %></i>
</p>
<p>
<%= link_to "Click here", post_url(@comment.post) %> to have your say!
</p>
<p>
Have a great day!
</p>
</body>
<footer>
Annoyed? You can <%= link_to("turn off notifications here", edit_user_registration_url) %>.
</footer>
</html>

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<meta content='text/html; charset=utf-8' http-equiv='content-type' />
</head>
<body>
<h1>Hello!</h1>
<p>
The post you're following <b><%= @post.title %></b> has a new status:
<span style='background-color: <%= @post.post_status.color %>; color: white;'%>>
<%= @post.post_status.name %>
</span>
</p>
<p>
<%= link_to "Click here", post_url(@post) %> to learn more!
</p>
<p>
Have a great day!
</p>
</body>
<footer>
Annoyed? You can <%= link_to("turn off notifications here", edit_user_registration_url) %>.
</footer>
</html>

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<meta content='text/html; charset=utf-8' http-equiv='content-type' />
</head>
<body>
<h1>Hello!</h1>
<p>
There is a new update on the post you're following <b><%= @comment.post.title %></b>:
</p>
<p>
<i><%= @comment.body %></i>
</p>
<p>
<%= link_to "Click here", post_url(@comment.post) %> to have your say!
</p>
<p>
Have a great day!
</p>
</body>
<footer>
Annoyed? You can <%= link_to("turn off notifications here", edit_user_registration_url) %>.
</footer>
</html>

View File

@@ -1,17 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<meta content='text/html; charset=UTF-8' http-equiv='Content-Type' />
<meta content='text/html; charset=utf-8' http-equiv='content-type' />
</head>
<body>
<h1>Hello, <%= @user.full_name %></h1>
<p>
There is a new comment by <%= @comment.user.full_name %> on your post <b><%= @comment.post.title %></b>
There is a new comment by <b><%= @comment.user.full_name %></b> on your post <b><%= @comment.post.title %></b>:
</p>
<p>
<i><%= @comment.body %></i>
</p>
<p>
<%= link_to "Click here", post_url(@comment.post) %> to have your say!
</p>
<p>
Have a great day!
</p>
<p> To see this comment, <%= link_to "Click here", post_url(@comment.post) %> </p>
<p>Have a great day!</p>
</body>
<footer>
Annoyed ? You can <%= link_to("turn off notifications here", edit_user_registration_url) %>
Annoyed? You can <%= link_to("turn off notifications here", edit_user_registration_url) %>.
</footer>
</html>

View File

@@ -21,10 +21,12 @@ default: &default
host: db
username: <%= ENV['POSTGRES_USER'] %>
password: <%= ENV['POSTGRES_PASSWORD'] %>
database: <%= ENV['POSTGRES_USER'] %>
development:
<<: *default
database: <%= ENV['POSTGRES_USER'] %>
# The specified database role being used to connect to postgres.
# To create additional roles in postgres see `$ createuser --help`.
# When left blank, postgres will use the default role. This is
@@ -57,7 +59,8 @@ development:
# Do not set this db to the same as development or production.
test:
<<: *default
database: app_test
database: <%= ENV['POSTGRES_USER'] %>_test
# As with config/credentials.yml, you never want to store sensitive information,
# like your database password, in your source code. If your source code is
@@ -80,3 +83,5 @@ test:
#
production:
<<: *default
database: <%= ENV['POSTGRES_USER'] %>

View File

@@ -15,9 +15,12 @@ Rails.application.routes.draw do
devise_for :users
resources :posts, only: [:index, :create, :show, :update] do
resource :follows, only: [:create, :destroy]
resources :follows, only: [:index]
resource :likes, only: [:create, :destroy]
resources :likes, only: [:index]
resources :comments, only: [:index, :create, :update]
resources :post_status_changes, only: [:index]
end
resources :boards, only: [:index, :create, :update, :destroy, :show] do

View File

@@ -0,0 +1,12 @@
class CreateFollows < ActiveRecord::Migration[6.0]
def change
create_table :follows do |t|
t.references :user, null: false, foreign_key: true
t.references :post, null: false, foreign_key: true
t.timestamps
end
add_index :follows, [:user_id, :post_id], unique: true
end
end

View File

@@ -0,0 +1,11 @@
class CreatePostStatusChanges < ActiveRecord::Migration[6.0]
def change
create_table :post_status_changes do |t|
t.references :user, null: false, foreign_key: true
t.references :post, null: false, foreign_key: true
t.references :post_status, foreign_key: true
t.timestamps
end
end
end

View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2021_01_26_215831) do
ActiveRecord::Schema.define(version: 2022_05_21_161950) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -37,6 +37,16 @@ ActiveRecord::Schema.define(version: 2021_01_26_215831) do
t.index ["user_id"], name: "index_comments_on_user_id"
end
create_table "follows", force: :cascade do |t|
t.bigint "user_id", null: false
t.bigint "post_id", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["post_id"], name: "index_follows_on_post_id"
t.index ["user_id", "post_id"], name: "index_follows_on_user_id_and_post_id", unique: true
t.index ["user_id"], name: "index_follows_on_user_id"
end
create_table "likes", force: :cascade do |t|
t.bigint "user_id", null: false
t.bigint "post_id", null: false
@@ -47,6 +57,17 @@ ActiveRecord::Schema.define(version: 2021_01_26_215831) do
t.index ["user_id"], name: "index_likes_on_user_id"
end
create_table "post_status_changes", force: :cascade do |t|
t.bigint "user_id", null: false
t.bigint "post_id", null: false
t.bigint "post_status_id"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["post_id"], name: "index_post_status_changes_on_post_id"
t.index ["post_status_id"], name: "index_post_status_changes_on_post_status_id"
t.index ["user_id"], name: "index_post_status_changes_on_user_id"
end
create_table "post_statuses", force: :cascade do |t|
t.string "name", null: false
t.string "color", null: false
@@ -93,8 +114,13 @@ ActiveRecord::Schema.define(version: 2021_01_26_215831) do
add_foreign_key "comments", "comments", column: "parent_id"
add_foreign_key "comments", "posts"
add_foreign_key "comments", "users"
add_foreign_key "follows", "posts"
add_foreign_key "follows", "users"
add_foreign_key "likes", "posts"
add_foreign_key "likes", "users"
add_foreign_key "post_status_changes", "post_statuses"
add_foreign_key "post_status_changes", "posts"
add_foreign_key "post_status_changes", "users"
add_foreign_key "posts", "boards"
add_foreign_key "posts", "post_statuses"
add_foreign_key "posts", "users"

View File

@@ -6,7 +6,7 @@ services:
- POSTGRES_USER
- POSTGRES_PASSWORD
volumes:
- ./tmp/db:/var/lib/postgresql/data
- dbdata:/var/lib/postgresql/data
web:
build:
context: .
@@ -26,4 +26,7 @@ services:
ports:
- "3000:3000"
depends_on:
- db
- db
volumes:
dbdata:

View File

@@ -0,0 +1,6 @@
FactoryBot.define do
factory :follow do
user
post
end
end

View File

@@ -0,0 +1,7 @@
FactoryBot.define do
factory :post_status_change do
user
post
post_status
end
end

View File

@@ -8,15 +8,16 @@ RSpec.describe UserMailer, type: :mailer do
let(:mail) { UserMailer.notify_post_owner(comment: comment) }
it "renders the headers" do
expect(mail.subject).to eq("[#{ENV.fetch('APP_NAME')}] - New comment on #{post.title}")
expect(mail.subject).to eq("[#{ENV.fetch('APP_NAME')}] New comment on #{post.title}")
expect(mail.to).to eq(["notified@example.com"])
expect(mail.from).to eq(["notifications@example.com"])
end
it "renders the body" do
expect(mail.body.encoded).to include("Hello, #{user.full_name}")
expect(mail.body.encoded).to include("There is a new comment by")
expect(mail.body.encoded).to include('Annoyed ? You can <a href="http://localhost:3000/users/edit">turn off notifications here</a>')
it "renders the user name, post title, replier name and comment body" do
expect(mail.body.encoded).to include(user.full_name)
expect(mail.body.encoded).to include(post.title)
expect(mail.body.encoded).to include(comment.user.full_name)
expect(mail.body.encoded).to include(comment.body)
end
end
end

View File

@@ -0,0 +1,26 @@
require 'rails_helper'
RSpec.describe Follow, type: :model do
let(:follow) { FactoryBot.build(:follow) }
it 'is valid' do
expect(follow).to be_valid
end
it 'must have a user_id' do
follow.user = nil
expect(follow).to be_invalid
end
it 'must have a post_id' do
follow.post = nil
expect(follow).to be_invalid
end
it 'must be unique on user and post' do
follow
f = Follow.new(user_id: follow.user_id, post_id: follow.post_id)
expect(f).to be_invalid
end
end

View File

@@ -0,0 +1,24 @@
require 'rails_helper'
RSpec.describe PostStatusChange, type: :model do
let(:post_status_change) { FactoryBot.build(:post_status_change) }
it 'should be valid' do
expect(post_status_change).to be_valid
end
it 'must have a post' do
post_status_change.post = nil
expect(post_status_change).to be_invalid
end
it 'must have a user' do
post_status_change.user = nil
expect(post_status_change).to be_invalid
end
it 'can have a null post status' do
post_status_change.post_status = nil
expect(post_status_change).to be_valid
end
end