From dad382d2b100baed9dc60cf21410bf209c44994f Mon Sep 17 00:00:00 2001 From: Riccardo Graziosi <31478034+riggraz@users.noreply.github.com> Date: Sat, 28 May 2022 11:03:36 +0200 Subject: [PATCH] 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 ... --- app/controllers/comments_controller.rb | 26 ++++++- app/controllers/follows_controller.rb | 53 ++++++++++++++ .../post_status_changes_controller.rb | 16 +++++ app/controllers/posts_controller.rb | 26 ++++++- .../actions/Comment/handleCommentReplies.ts | 14 +++- .../actions/Comment/submitComment.ts | 2 + .../actions/Follow/requestFollow.ts | 58 +++++++++++++++ app/javascript/actions/Follow/submitFollow.ts | 47 ++++++++++++ .../requestPostStatusChanges.ts | 58 +++++++++++++++ .../submittedPostStatusChange.ts | 14 ++++ .../components/Comments/Comment.tsx | 9 ++- .../components/Comments/CommentList.tsx | 2 +- .../components/Comments/CommentsP.tsx | 11 ++- .../components/Comments/NewComment.tsx | 49 +++++++++---- .../Comments/NewCommentUpdateSection.tsx | 33 +++++++++ app/javascript/components/Post/ActionBox.tsx | 33 +++++++++ app/javascript/components/Post/PostP.tsx | 52 ++++++++++++-- .../components/Post/PostUpdateList.tsx | 22 +++++- app/javascript/components/Post/index.tsx | 3 + .../components/shared/CustomTexts.tsx | 4 ++ .../components/shared/PostStatusLabel.tsx | 4 +- app/javascript/containers/Comments.tsx | 8 ++- app/javascript/containers/Post.tsx | 39 +++++++++- .../helpers/{friendlyDate.js => datetime.ts} | 14 ++-- .../interfaces/IPostStatusChange.ts | 8 +++ app/javascript/interfaces/json/IFollow.ts | 7 ++ .../interfaces/json/IPostStatusChange.ts | 8 +++ app/javascript/reducers/commentsReducer.ts | 2 + app/javascript/reducers/currentPostReducer.ts | 50 ++++++++++++- .../reducers/postStatusChangesReducer.ts | 71 +++++++++++++++++++ app/javascript/reducers/replyFormReducer.ts | 10 +++ app/javascript/reducers/replyFormsReducer.ts | 2 + .../stylesheets/components/Comments.scss | 16 +++++ .../stylesheets/components/Post.scss | 36 ++++++++-- .../stylesheets/general/_components.scss | 2 +- .../stylesheets/general/_custom_texts.scss | 9 +++ .../stylesheets/general/_index.scss | 39 +++++++++- app/mailers/application_mailer.rb | 2 +- app/mailers/user_mailer.rb | 41 ++++++++++- app/models/follow.rb | 6 ++ app/models/post.rb | 4 ++ app/models/post_status_change.rb | 5 ++ app/views/devise/registrations/edit.html.erb | 2 +- app/views/posts/show.html.erb | 1 + .../user_mailer/notify_comment_owner.html.erb | 24 +++++++ ...notify_followers_of_post_status_change.erb | 24 +++++++ .../notify_followers_of_post_update.erb | 24 +++++++ .../user_mailer/notify_post_owner.html.erb | 17 +++-- config/database.yml | 9 ++- config/routes.rb | 3 + db/migrate/20220512184400_create_follows.rb | 12 ++++ ...220521161950_create_post_status_changes.rb | 11 +++ db/schema.rb | 28 +++++++- docker-compose.yml | 7 +- spec/factories/follows.rb | 6 ++ spec/factories/post_status_changes.rb | 7 ++ spec/mailers/user_mailer_spec.rb | 11 +-- spec/models/follow_spec.rb | 26 +++++++ spec/models/post_status_change_spec.rb | 24 +++++++ 59 files changed, 1080 insertions(+), 71 deletions(-) create mode 100644 app/controllers/follows_controller.rb create mode 100644 app/controllers/post_status_changes_controller.rb create mode 100644 app/javascript/actions/Follow/requestFollow.ts create mode 100644 app/javascript/actions/Follow/submitFollow.ts create mode 100644 app/javascript/actions/PostStatusChange/requestPostStatusChanges.ts create mode 100644 app/javascript/actions/PostStatusChange/submittedPostStatusChange.ts create mode 100644 app/javascript/components/Comments/NewCommentUpdateSection.tsx create mode 100644 app/javascript/components/Post/ActionBox.tsx rename app/javascript/helpers/{friendlyDate.js => datetime.ts} (69%) create mode 100644 app/javascript/interfaces/IPostStatusChange.ts create mode 100644 app/javascript/interfaces/json/IFollow.ts create mode 100644 app/javascript/interfaces/json/IPostStatusChange.ts create mode 100644 app/javascript/reducers/postStatusChangesReducer.ts create mode 100644 app/models/follow.rb create mode 100644 app/models/post_status_change.rb create mode 100644 app/views/user_mailer/notify_comment_owner.html.erb create mode 100644 app/views/user_mailer/notify_followers_of_post_status_change.erb create mode 100644 app/views/user_mailer/notify_followers_of_post_update.erb create mode 100644 db/migrate/20220512184400_create_follows.rb create mode 100644 db/migrate/20220521161950_create_post_status_changes.rb create mode 100644 spec/factories/follows.rb create mode 100644 spec/factories/post_status_changes.rb create mode 100644 spec/models/follow_spec.rb create mode 100644 spec/models/post_status_change_spec.rb diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb index 80c55139..c5583583 100644 --- a/app/controllers/comments_controller.rb +++ b/app/controllers/comments_controller.rb @@ -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 diff --git a/app/controllers/follows_controller.rb b/app/controllers/follows_controller.rb new file mode 100644 index 00000000..a49e8c49 --- /dev/null +++ b/app/controllers/follows_controller.rb @@ -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 \ No newline at end of file diff --git a/app/controllers/post_status_changes_controller.rb b/app/controllers/post_status_changes_controller.rb new file mode 100644 index 00000000..68980cdf --- /dev/null +++ b/app/controllers/post_status_changes_controller.rb @@ -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 \ No newline at end of file diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index df7ddffa..9d153f9b 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -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 diff --git a/app/javascript/actions/Comment/handleCommentReplies.ts b/app/javascript/actions/Comment/handleCommentReplies.ts index db771197..fd8519eb 100644 --- a/app/javascript/actions/Comment/handleCommentReplies.ts +++ b/app/javascript/actions/Comment/handleCommentReplies.ts @@ -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; \ No newline at end of file + SetCommentReplyBodyAction | + ToggleCommentIsPostUpdateFlag; \ No newline at end of file diff --git a/app/javascript/actions/Comment/submitComment.ts b/app/javascript/actions/Comment/submitComment.ts index 63deb6c5..9286b798 100644 --- a/app/javascript/actions/Comment/submitComment.ts +++ b/app/javascript/actions/Comment/submitComment.ts @@ -52,6 +52,7 @@ export const submitComment = ( postId: number, body: string, parentId: number, + isPostUpdate: boolean, authenticityToken: string, ): ThunkAction> => async (dispatch) => { dispatch(commentSubmitStart(parentId)); @@ -64,6 +65,7 @@ export const submitComment = ( comment: { body, parent_id: parentId, + is_post_update: isPostUpdate, }, }), }); diff --git a/app/javascript/actions/Follow/requestFollow.ts b/app/javascript/actions/Follow/requestFollow.ts new file mode 100644 index 00000000..656d581c --- /dev/null +++ b/app/javascript/actions/Follow/requestFollow.ts @@ -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> => 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)); + } +}; \ No newline at end of file diff --git a/app/javascript/actions/Follow/submitFollow.ts b/app/javascript/actions/Follow/submitFollow.ts new file mode 100644 index 00000000..f2fdc783 --- /dev/null +++ b/app/javascript/actions/Follow/submitFollow.ts @@ -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> => 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'); + } +} \ No newline at end of file diff --git a/app/javascript/actions/PostStatusChange/requestPostStatusChanges.ts b/app/javascript/actions/PostStatusChange/requestPostStatusChanges.ts new file mode 100644 index 00000000..7e3ff616 --- /dev/null +++ b/app/javascript/actions/PostStatusChange/requestPostStatusChanges.ts @@ -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; +} + +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, +): 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> => 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)); + } +}; \ No newline at end of file diff --git a/app/javascript/actions/PostStatusChange/submittedPostStatusChange.ts b/app/javascript/actions/PostStatusChange/submittedPostStatusChange.ts new file mode 100644 index 00000000..f24ffc11 --- /dev/null +++ b/app/javascript/actions/PostStatusChange/submittedPostStatusChange.ts @@ -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, +}); \ No newline at end of file diff --git a/app/javascript/components/Comments/Comment.tsx b/app/javascript/components/Comments/Comment.tsx index 3dfc7cc7..3642619c 100644 --- a/app/javascript/components/Comments/Comment.tsx +++ b/app/javascript/components/Comments/Comment.tsx @@ -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) => (
- + {userFullName} { isPostUpdate ? Post update : null }
@@ -89,12 +89,15 @@ const Comment = ({ null} handleSubmit={handleSubmitComment} isLoggedIn={isLoggedIn} + isPowerUser={isPowerUser} userEmail={currentUserEmail} /> : diff --git a/app/javascript/components/Comments/CommentList.tsx b/app/javascript/components/Comments/CommentList.tsx index 79662f82..5568e7c5 100644 --- a/app/javascript/components/Comments/CommentList.tsx +++ b/app/javascript/components/Comments/CommentList.tsx @@ -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; diff --git a/app/javascript/components/Comments/CommentsP.tsx b/app/javascript/components/Comments/CommentsP.tsx index 2c12cef4..1a25c011 100644 --- a/app/javascript/components/Comments/CommentsP.tsx +++ b/app/javascript/components/Comments/CommentsP.tsx @@ -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 { ); } - _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 { toggleCommentReply, setCommentReplyBody, + toggleCommentIsPostUpdateFlag, } = this.props; const postReply = replyForms.find(replyForm => replyForm.commentId === null); @@ -82,6 +86,7 @@ class CommentsP extends React.Component { { 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 { { error ? {error} : null }
- activity • {comments.length} comments + activity • {comments.length} comment{comments.length === 1 ? '' : 's'}
( @@ -33,18 +45,29 @@ const NewComment = ({ { isLoggedIn ? - -