From 2b4c5ecff4e4457c49a14d3ed0095cc9f0e1f758 Mon Sep 17 00:00:00 2001 From: syuilo Date: Tue, 14 Feb 2017 13:59:26 +0900 Subject: [PATCH] Implement the poll feature Closes #164 --- src/api/endpoints.ts | 1 + src/api/endpoints/posts/create.js | 57 +++++++++- src/api/endpoints/posts/polls/vote.js | 101 ++++++++++++++++++ src/api/endpoints/posts/show.js | 3 +- src/api/models/poll-vote.ts | 3 + src/api/serializers/notification.ts | 1 + src/api/serializers/post.ts | 36 ++++--- src/web/app/common/tags/index.js | 2 + src/web/app/common/tags/poll-editor.tag | 47 ++++++++ src/web/app/common/tags/poll.tag | 73 +++++++++++++ src/web/app/desktop/tags/notifications.tag | 6 ++ src/web/app/desktop/tags/post-form.tag | 12 +++ src/web/app/desktop/tags/timeline-post.tag | 4 + .../app/mobile/tags/notification-preview.tag | 34 +++--- src/web/app/mobile/tags/notification.tag | 93 ++++++++++++---- src/web/app/mobile/tags/post-form.tag | 12 +++ src/web/app/mobile/tags/timeline-post.tag | 4 + test/api.js | 15 +++ 18 files changed, 448 insertions(+), 56 deletions(-) create mode 100644 src/api/endpoints/posts/polls/vote.js create mode 100644 src/api/models/poll-vote.ts create mode 100644 src/web/app/common/tags/poll-editor.tag create mode 100644 src/web/app/common/tags/poll.tag diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts index e4abc06f5..963d1df25 100644 --- a/src/api/endpoints.ts +++ b/src/api/endpoints.ts @@ -93,6 +93,7 @@ export default [ { name: 'posts/likes/delete', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'like-write' }, { name: 'posts/favorites/create', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'favorite-write' }, { name: 'posts/favorites/delete', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'favorite-write' }, + { name: 'posts/polls/vote', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'vote-write' }, { name: 'messaging/history', shouldBeSignin: true, kind: 'messaging-read' }, { name: 'messaging/unread', shouldBeSignin: true, kind: 'messaging-read' }, diff --git a/src/api/endpoints/posts/create.js b/src/api/endpoints/posts/create.js index e7c1d0cec..61f8e714f 100644 --- a/src/api/endpoints/posts/create.js +++ b/src/api/endpoints/posts/create.js @@ -161,9 +161,59 @@ module.exports = (params, user, app) => replyTo = null; } - // テキストが無いかつ添付ファイルが無いかつRepostも無かったらエラー - if (text === null && files === null && repost === null) { - return rej('text, media_ids or repost_id is required'); + // Get 'poll' parameter + let poll = params.poll; + if (poll !== undefined && poll !== null) { + // 選択肢が無かったらエラー + if (poll.choices == null) { + return rej('poll choices is required'); + } + + // 選択肢が配列でなかったらエラー + if (!Array.isArray(poll.choices)) { + return rej('poll choices must be an array'); + } + + // Validate each choices + const shouldReject = poll.choices.some(choice => { + if (typeof choice !== 'string') return true; + if (choice.trim().length === 0) return true; + if (choice.trim().length > 100) return true; + }); + + if (shouldReject) { + return rej('invalid poll choices'); + } + + // Trim choices + poll.choices = poll.choices.map(choice => choice.trim()); + + // Drop duplicates + poll.choices = poll.choices.filter((x, i, s) => s.indexOf(x) == i); + + // 選択肢がひとつならエラー + if (poll.choices.length == 1) { + return rej('poll choices must be ひとつ以上'); + } + + // 選択肢が多すぎてもエラー + if (poll.choices.length > 10) { + return rej('many poll choices'); + } + + // serialize + poll.choices = poll.choices.map((choice, i) => ({ + id: i, // IDを付与 + text: choice, + votes: 0 + })); + } else { + poll = null; + } + + // テキストが無いかつ添付ファイルが無いかつRepostも無いかつ投票も無かったらエラー + if (text === null && files === null && repost === null && poll === null) { + return rej('text, media_ids, repost_id or poll is required'); } // 投稿を作成 @@ -172,6 +222,7 @@ module.exports = (params, user, app) => media_ids: media ? files.map(file => file._id) : undefined, reply_to_id: replyTo ? replyTo._id : undefined, repost_id: repost ? repost._id : undefined, + poll: poll ? poll : undefined, text: text, user_id: user._id, app_id: app ? app._id : null diff --git a/src/api/endpoints/posts/polls/vote.js b/src/api/endpoints/posts/polls/vote.js new file mode 100644 index 000000000..f1842069d --- /dev/null +++ b/src/api/endpoints/posts/polls/vote.js @@ -0,0 +1,101 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Vote from '../../../models/poll-vote'; +import Post from '../../../models/post'; +import notify from '../../../common/notify'; + +/** + * Vote poll of a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Validate id + if (!mongo.ObjectID.isValid(postId)) { + return rej('incorrect post_id'); + } + + // Get votee + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + if (post.poll == null) { + return rej('poll not found'); + } + + // Get 'choice' parameter + const choice = params.choice; + if (choice == null) { + return rej('choice is required'); + } + + // Validate choice + if (!post.poll.choices.some(x => x.id == choice)) { + return rej('invalid choice'); + } + + // Check arleady voted + const exist = await Vote.findOne({ + post_id: post._id, + user_id: user._id + }); + + if (exist !== null) { + return rej('already voted'); + } + + // Create vote + await Vote.insert({ + created_at: new Date(), + post_id: post._id, + user_id: user._id, + choice: choice + }); + + // Send response + res(); + + const inc = {}; + inc[`poll.choices.${ findWithAttr(post.poll.choices, 'id', choice) }.votes`] = 1; + + console.log(inc); + + // Increment likes count + Post.update({ _id: post._id }, { + $inc: inc + }); + + // Notify + notify(post.user_id, user._id, 'poll_vote', { + post_id: post._id, + choice: choice + }); +}); + +function findWithAttr(array, attr, value) { + for (let i = 0; i < array.length; i += 1) { + if(array[i][attr] === value) { + return i; + } + } + return -1; +} diff --git a/src/api/endpoints/posts/show.js b/src/api/endpoints/posts/show.js index f399d86c8..1b9a747a8 100644 --- a/src/api/endpoints/posts/show.js +++ b/src/api/endpoints/posts/show.js @@ -39,7 +39,6 @@ module.exports = (params, user) => // Serialize res(await serialize(post, user, { - serializeReplyTo: true, - includeIsLiked: true + detail: true })); }); diff --git a/src/api/models/poll-vote.ts b/src/api/models/poll-vote.ts new file mode 100644 index 000000000..af77a2643 --- /dev/null +++ b/src/api/models/poll-vote.ts @@ -0,0 +1,3 @@ +import db from '../../db/mongodb'; + +export default db.get('poll_votes') as any; // fuck type definition diff --git a/src/api/serializers/notification.ts b/src/api/serializers/notification.ts index 076fef5fe..df86218aa 100644 --- a/src/api/serializers/notification.ts +++ b/src/api/serializers/notification.ts @@ -54,6 +54,7 @@ export default (notification: any) => new Promise(async (resolve, reject case 'repost': case 'quote': case 'like': + case 'poll_vote': // Populate post _notification.post = await serializePost(_notification.post_id, me); break; diff --git a/src/api/serializers/post.ts b/src/api/serializers/post.ts index 5473cd1a0..575cfc239 100644 --- a/src/api/serializers/post.ts +++ b/src/api/serializers/post.ts @@ -6,6 +6,7 @@ import * as mongo from 'mongodb'; import Post from '../models/post'; import Like from '../models/like'; +import Vote from '../models/poll-vote'; import serializeApp from './app'; import serializeUser from './user'; import serializeDriveFile from './drive-file'; @@ -23,15 +24,11 @@ const self = ( post: any, me?: any, options?: { - serializeReplyTo: boolean, - serializeRepost: boolean, - includeIsLiked: boolean + detail: boolean } ) => new Promise(async (resolve, reject) => { const opts = options || { - serializeReplyTo: true, - serializeRepost: true, - includeIsLiked: true + detail: true, }; let _post: any; @@ -72,26 +69,35 @@ const self = ( )); } - if (_post.reply_to_id && opts.serializeReplyTo) { + if (_post.reply_to_id && opts.detail) { // Populate reply to post _post.reply_to = await self(_post.reply_to_id, me, { - serializeReplyTo: false, - serializeRepost: false, - includeIsLiked: false + detail: false }); } - if (_post.repost_id && opts.serializeRepost) { + if (_post.repost_id && opts.detail) { // Populate repost _post.repost = await self(_post.repost_id, me, { - serializeReplyTo: _post.text == null, - serializeRepost: _post.text == null, - includeIsLiked: _post.text == null + detail: _post.text == null }); } + // Poll + if (me && _post.poll && opts.detail) { + const vote = await Vote + .findOne({ + user_id: me._id, + post_id: id + }); + + if (vote != null) { + _post.poll.choices.filter(c => c.id == vote.choice)[0].is_voted = true; + } + } + // Check if it is liked - if (me && opts.includeIsLiked) { + if (me && opts.detail) { const liked = await Like .count({ user_id: me._id, diff --git a/src/web/app/common/tags/index.js b/src/web/app/common/tags/index.js index ef61d51ba..692a7070a 100644 --- a/src/web/app/common/tags/index.js +++ b/src/web/app/common/tags/index.js @@ -18,3 +18,5 @@ require('./signin-history.tag'); require('./api-info.tag'); require('./twitter-setting.tag'); require('./authorized-apps.tag'); +require('./poll.tag'); +require('./poll-editor.tag'); diff --git a/src/web/app/common/tags/poll-editor.tag b/src/web/app/common/tags/poll-editor.tag new file mode 100644 index 000000000..04c712b61 --- /dev/null +++ b/src/web/app/common/tags/poll-editor.tag @@ -0,0 +1,47 @@ + +
    +
  • + + +
  • +
+ + + +
diff --git a/src/web/app/common/tags/poll.tag b/src/web/app/common/tags/poll.tag new file mode 100644 index 000000000..8c14b895e --- /dev/null +++ b/src/web/app/common/tags/poll.tag @@ -0,0 +1,73 @@ + +
    +
  • +
    + + + { text } + ({ votes }票) + +
  • +
+

{ total }人が投票

+ + +
diff --git a/src/web/app/desktop/tags/notifications.tag b/src/web/app/desktop/tags/notifications.tag index aaf72bb2c..3c5f3d89e 100644 --- a/src/web/app/desktop/tags/notifications.tag +++ b/src/web/app/desktop/tags/notifications.tag @@ -39,6 +39,12 @@

{ notification.post.user.name }

{ getPostSummary(notification.post) } + + avatar + +

{ notification._datetext }{ notifications[i + 1]._datetext }

diff --git a/src/web/app/desktop/tags/post-form.tag b/src/web/app/desktop/tags/post-form.tag index 872e92881..d4b93a6a1 100644 --- a/src/web/app/desktop/tags/post-form.tag +++ b/src/web/app/desktop/tags/post-form.tag @@ -10,9 +10,11 @@

残り{ 4 - files.length }

+
+

のこり{ 1000 - refs.text.value.length }文字