diff --git a/composer.json b/composer.json index 123dcac1..74cf0452 100755 --- a/composer.json +++ b/composer.json @@ -21,8 +21,8 @@ "require": { "flarum/core": "^1.3.0" }, - "replace": { - "reflar/polls": "^1.3.4" + "conflict": { + "reflar/polls": "*" }, "authors": [ { diff --git a/extend.php b/extend.php index fc433053..f19f8d81 100755 --- a/extend.php +++ b/extend.php @@ -13,8 +13,8 @@ use Flarum\Api\Controller; use Flarum\Api\Serializer\DiscussionSerializer; +use Flarum\Api\Serializer\ForumSerializer; use Flarum\Api\Serializer\PostSerializer; -use Flarum\Api\Serializer\UserSerializer; use Flarum\Discussion\Discussion; use Flarum\Extend; use Flarum\Post\Event\Saving; @@ -34,10 +34,10 @@ new Extend\Locales(__DIR__.'/resources/locale'), (new Extend\Routes('api')) + ->post('/fof/polls', 'fof.polls.create', Controllers\CreatePollController::class) ->get('/fof/polls/{id}', 'fof.polls.show', Controllers\ShowPollController::class) ->patch('/fof/polls/{id}', 'fof.polls.edit', Controllers\EditPollController::class) ->delete('/fof/polls/{id}', 'fof.polls.delete', Controllers\DeletePollController::class) - ->patch('/fof/polls/{id}/vote', 'fof.polls.vote', Controllers\VotePollController::class) ->patch('/fof/polls/{id}/votes', 'fof.polls.votes', Controllers\MultipleVotesPollController::class), (new Extend\Model(Post::class)) @@ -58,13 +58,14 @@ }), (new Extend\ApiSerializer(PostSerializer::class)) - ->hasMany('polls', PollSerializer::class), + ->hasMany('polls', PollSerializer::class) + ->attribute('canStartPoll', function (PostSerializer $serializer, Post $post): bool { + return $serializer->getActor()->can('startPoll', $post); + }), - (new Extend\ApiSerializer(UserSerializer::class)) - ->attributes(function (UserSerializer $serializer): array { - return [ - 'canStartPolls' => $serializer->getActor()->can('discussion.polls.start'), // used for discussion composer - ]; + (new Extend\ApiSerializer(ForumSerializer::class)) + ->attribute('canStartPolls', function (ForumSerializer $serializer): bool { + return $serializer->getActor()->can('discussion.polls.start'); }), (new Extend\ApiController(Controller\ListDiscussionsController::class)) @@ -94,8 +95,12 @@ ->command(Console\RefreshVoteCountCommand::class), (new Extend\Policy()) - ->modelPolicy(Poll::class, Access\PollPolicy::class), + ->modelPolicy(Poll::class, Access\PollPolicy::class) + ->modelPolicy(Post::class, Access\PostPolicy::class), (new Extend\Settings()) ->serializeToForum('allowPollOptionImage', 'fof-polls.allowOptionImage', 'boolval'), + + (new Extend\ModelVisibility(Poll::class)) + ->scope(Access\ScopePollVisibility::class), ]; diff --git a/js/src/admin/index.ts b/js/src/admin/index.ts index db5bd8c9..8f04c0b4 100755 --- a/js/src/admin/index.ts +++ b/js/src/admin/index.ts @@ -33,6 +33,14 @@ app.initializers.add('fof/polls', () => { }, 'start' ) + .registerPermission( + { + icon: 'fas fa-pencil-alt', + label: app.translator.trans('fof-polls.admin.permissions.self_post_edit'), + permission: 'polls.selfPostEdit', + }, + 'start' + ) .registerPermission( { icon: 'fas fa-signal', diff --git a/js/src/forum/addComposerItems.js b/js/src/forum/addComposerItems.js index 69f8c3b4..62e4e97e 100644 --- a/js/src/forum/addComposerItems.js +++ b/js/src/forum/addComposerItems.js @@ -18,7 +18,7 @@ export const addToComposer = (composer) => { // Add button to DiscussionComposer header extend(composer.prototype, 'headerItems', function (items) { const discussion = this.composer.body?.attrs?.discussion; - const canStartPoll = discussion?.canStartPoll() ?? app.session.user?.canStartPolls(); + const canStartPoll = discussion?.canStartPoll() ?? app.forum.canStartPolls(); if (canStartPoll) { items.add( diff --git a/js/src/forum/addPollsToPost.js b/js/src/forum/addPollsToPost.js index 48d8d267..bfe02493 100644 --- a/js/src/forum/addPollsToPost.js +++ b/js/src/forum/addPollsToPost.js @@ -9,7 +9,7 @@ export default () => { extend(CommentPost.prototype, 'content', function (content) { const post = this.attrs.post; - if (post.polls()) { + if ((!post.isHidden() || this.revealContent) && post.polls()) { for (const poll of post.polls()) { if (poll) { content.push(); @@ -26,8 +26,8 @@ export default () => { (poll) => poll && [ poll.data?.attributes, - poll.options()?.map((option) => option?.data?.attributes), - poll.myVotes()?.map((vote) => vote.option()?.id()), + poll.options().map?.((option) => option?.data?.attributes), + poll.myVotes().map?.((vote) => vote.option()?.id()), ] ); diff --git a/js/src/forum/addPostControls.js b/js/src/forum/addPostControls.js new file mode 100644 index 00000000..3f066e1e --- /dev/null +++ b/js/src/forum/addPostControls.js @@ -0,0 +1,44 @@ +import app from 'flarum/forum/app'; + +import { extend } from 'flarum/common/extend'; +import PostControls from 'flarum/forum/utils/PostControls'; +import CreatePollModal from './components/CreatePollModal'; +import Button from 'flarum/common/components/Button'; + +export default () => { + const createPoll = (post) => + app.modal.show(CreatePollModal, { + onsubmit: (data) => + app.store + .createRecord('polls') + .save( + { + ...data, + relationships: { + post, + }, + }, + { + data: { + include: 'options,myVotes,myVotes.option', + }, + } + ) + .then((poll) => { + post.rawRelationship('polls')?.push?.({ type: 'polls', id: poll.id() }); + + return poll; + }), + }); + + extend(PostControls, 'moderationControls', function (items, post) { + if (!post.isHidden() && post.canStartPoll()) { + items.add( + 'addPoll', + + ); + } + }); +}; diff --git a/js/src/forum/components/CreatePollModal.js b/js/src/forum/components/CreatePollModal.js index a513ceab..dda42b44 100755 --- a/js/src/forum/components/CreatePollModal.js +++ b/js/src/forum/components/CreatePollModal.js @@ -273,9 +273,18 @@ export default class CreatePollModal extends Modal { return; } - this.attrs.onsubmit(data); + const promise = this.attrs.onsubmit(data); - app.modal.close(); + if (promise instanceof Promise) { + this.loading = true; + + promise.then(this.hide.bind(this), (err) => { + console.error(err); + this.loaded(); + }); + } else { + app.modal.close(); + } } formatDate(date, def = false) { @@ -289,7 +298,7 @@ export default class CreatePollModal extends Modal { dateToTimestamp(date) { const dayjsDate = dayjs(date); - if (!dayjsDate.isValid()) return null; + if (!dayjsDate.isValid()) return false; return dayjsDate.format(); } diff --git a/js/src/forum/components/PostPoll.js b/js/src/forum/components/PostPoll.js index 711a7f95..60bfdca7 100644 --- a/js/src/forum/components/PostPoll.js +++ b/js/src/forum/components/PostPoll.js @@ -11,21 +11,28 @@ import EditPollModal from './EditPollModal'; export default class PostPoll extends Component { oninit(vnode) { super.oninit(vnode); - this.poll = this.attrs.poll; - this.updateData(); + this.loadingOptions = false; } view() { - const poll = this.poll; + const poll = this.attrs.poll; + const options = poll.options() || []; let maxVotes = poll.allowMultipleVotes() ? poll.maxVotes() : 1; - if (maxVotes === 0) maxVotes = this.options.length; + if (maxVotes === 0) maxVotes = options.length; return ( -
+

{poll.question()}

+ + {poll.canSeeVoters() && ( + +
-
{this.options.map(this.viewOption.bind(this))}
- - {poll.canSeeVotes() - ? Button.component( - { - className: 'Button Button--primary PublicPollButton', - onclick: () => this.showVoters(), - }, - app.translator.trans('fof-polls.forum.public_poll') - ) - : ''} +
{options.map(this.viewOption.bind(this))}
- {app.session.user && !poll.canVote() && ( + {app.session.user && !poll.canVote() && !poll.hasEnded() && ( {app.translator.trans('fof-polls.forum.no_permission')} @@ -78,21 +75,23 @@ export default class PostPoll extends Component { } viewOption(opt) { - const hasVoted = this.myVotes.length > 0; - const totalVotes = this.poll.voteCount(); + const poll = this.attrs.poll; + const hasVoted = poll.myVotes()?.length > 0; + const totalVotes = poll.voteCount(); - const voted = this.myVotes.some((vote) => vote.option() === opt); + const voted = poll.myVotes()?.some?.((vote) => vote.option() === opt); const votes = opt.voteCount(); const percent = totalVotes > 0 ? Math.round((votes / totalVotes) * 100) : 0; // isNaN(null) is false, so we have to check type directly now that API always returns the field const canSeeVoteCount = typeof votes === 'number'; + const isDisabled = this.loadingOptions || (hasVoted && !poll.canChangeVote()); - const poll = ( + const bar = (
- {((!this.poll.hasEnded() && this.poll.canVote()) || !app.session.user) && ( + {((!poll.hasEnded() && poll.canVote()) || !app.session.user) && ( )} @@ -111,25 +110,21 @@ export default class PostPoll extends Component { ); return ( -
- - {poll} - +
+ {canSeeVoteCount ? ( + + {bar} + + ) : ( + bar + )}
); } - updateData() { - this.options = this.poll.options() || []; - this.myVotes = this.poll.myVotes() || []; - } - - onError(evt, error) { - evt.target.checked = false; - - throw error; - } - changeVote(option, evt) { if (!app.session.user) { app.modal.show(LogInModal); @@ -137,12 +132,9 @@ export default class PostPoll extends Component { return; } - // // if we click on our current vote, we want to "un-vote" - // if (this.myVotes.some((vote) => vote.option() === option)) option = null; - - const optionIds = new Set(this.poll.myVotes().map((v) => v.option().id())); + const optionIds = new Set(this.attrs.poll.myVotes().map?.((v) => v.option().id())); const isUnvoting = optionIds.delete(option.id()); - const allowsMultiple = this.poll.allowMultipleVotes(); + const allowsMultiple = this.attrs.poll.allowMultipleVotes(); if (!allowsMultiple) { optionIds.clear(); @@ -152,10 +144,13 @@ export default class PostPoll extends Component { optionIds.add(option.id()); } + this.loadingOptions = true; + m.redraw(); + return app .request({ method: 'PATCH', - url: `${app.forum.attribute('apiUrl')}/fof/polls/${this.poll.id()}/votes`, + url: `${app.forum.attribute('apiUrl')}/fof/polls/${this.attrs.poll.id()}/votes`, body: { data: { optionIds: Array.from(optionIds), @@ -165,28 +160,41 @@ export default class PostPoll extends Component { .then((res) => { app.store.pushPayload(res); - this.updateData(); - - m.redraw(); + // m.redraw(); }) .catch(() => { evt.target.checked = isUnvoting; + }) + .finally(() => { + this.loadingOptions = false; + + m.redraw(); }); } showVoters() { // Load all the votes only when opening the votes list app.modal.show(ListVotersModal, { - poll: this.poll, + poll: this.attrs.poll, post: this.attrs.post, }); } deletePoll() { if (confirm(app.translator.trans('fof-polls.forum.moderation.delete_confirm'))) { - this.poll.delete().then(() => { + this.attrs.poll.delete().then(() => { m.redraw.sync(); }); } } + + /** + * Attempting to use the `tooltipVisible` attr on the Tooltip component set to 'false' when no vote count + * caused the tooltip to break on click. This is a workaround to hide the tooltip when no vote count is available, + * called on 'onremove' of the Tooltip component. It doesn't always work as intended either, but it does the job. + */ + hideOptionTooltip(vnode) { + vnode.attrs.tooltipVisible = false; + vnode.state.updateVisibility(); + } } diff --git a/js/src/forum/extend.js b/js/src/forum/extend.js index 097394c1..17a40979 100644 --- a/js/src/forum/extend.js +++ b/js/src/forum/extend.js @@ -1,6 +1,6 @@ import Extend from 'flarum/common/extenders'; import Post from 'flarum/common/models/Post'; -import User from 'flarum/common/models/User'; +import Forum from 'flarum/common/models/Forum'; import Discussion from 'flarum/common/models/Discussion'; import Poll from './models/Poll'; import PollOption from './models/PollOption'; @@ -9,9 +9,9 @@ import PollVote from './models/PollVote'; export default [ new Extend.Store().add('polls', Poll).add('poll_options', PollOption).add('poll_votes', PollVote), - new Extend.Model(Post).hasMany('polls'), + new Extend.Model(Post).hasMany('polls').attribute('canStartPoll'), - new Extend.Model(User).attribute('canStartPolls'), + new Extend.Model(Forum).attribute('canStartPolls'), new Extend.Model(Discussion).attribute('hasPoll').attribute('canStartPoll'), ]; diff --git a/js/src/forum/index.js b/js/src/forum/index.js index e4732b1c..bf0b4400 100755 --- a/js/src/forum/index.js +++ b/js/src/forum/index.js @@ -3,6 +3,7 @@ import app from 'flarum/forum/app'; import addDiscussionBadge from './addDiscussionBadge'; import addComposerItems from './addComposerItems'; import addPollsToPost from './addPollsToPost'; +import addPostControls from './addPostControls'; export * from './components'; export * from './models'; @@ -11,6 +12,7 @@ app.initializers.add('fof/polls', () => { addDiscussionBadge(); addComposerItems(); addPollsToPost(); + addPostControls(); }); export { default as extend } from './extend'; diff --git a/js/src/forum/models/Poll.js b/js/src/forum/models/Poll.js index 23cf0421..4e6009ab 100755 --- a/js/src/forum/models/Poll.js +++ b/js/src/forum/models/Poll.js @@ -13,7 +13,7 @@ export default class Poll extends Model { canVote = Model.attribute('canVote'); canEdit = Model.attribute('canEdit'); canDelete = Model.attribute('canDelete'); - canSeeVotes = Model.attribute('canSeeVotes'); + canSeeVoters = Model.attribute('canSeeVoters'); canChangeVote = Model.attribute('canChangeVote'); options = Model.hasMany('options'); diff --git a/migrations/2019_07_01_000000_add_polls_table.php b/migrations/2019_07_01_000000_add_polls_table.php index a9c90409..46769f6d 100755 --- a/migrations/2019_07_01_000000_add_polls_table.php +++ b/migrations/2019_07_01_000000_add_polls_table.php @@ -9,63 +9,22 @@ * file that was distributed with this source code. */ -use FoF\Polls\Migrations\AbstractMigration; -use FoF\Polls\Poll; +use Flarum\Database\Migration; use Illuminate\Database\Schema\Blueprint; -use Illuminate\Database\Schema\Builder; -return AbstractMigration::make( - function (Builder $schema) { - $schema->create('polls', function (Blueprint $table) { - $table->increments('id'); +return Migration::createTable('polls', function (Blueprint $table) { + $table->increments('id'); - $table->string('question'); + $table->string('question'); - $table->integer('discussion_id')->unsigned(); - $table->integer('user_id')->unsigned()->nullable(); + $table->integer('discussion_id')->unsigned(); + $table->integer('user_id')->unsigned()->nullable(); - $table->boolean('public_poll'); + $table->boolean('public_poll'); - $table->timestamp('end_date')->nullable(); - $table->timestamps(); + $table->timestamp('end_date')->nullable(); + $table->timestamps(); - $table->foreign('discussion_id')->references('id')->on('discussions')->onDelete('cascade'); - $table->foreign('user_id')->references('id')->on('users')->onDelete('set null'); - }); - }, - function (Builder $schema) { - // delete polls whose discussion was deleted - $polls = Poll::doesntHave('discussion')->get(); - - if ($polls->count() !== 0) { - resolve('log')->info("[fof/polls] deleting {$polls->count()} polls"); - - foreach ($polls as $poll) { - /* - * @var Poll $poll - */ - resolve('log')->info("[fof/polls] |> deleting #{$poll->id}"); - - $poll->votes()->delete(); - $poll->options()->delete(); - $poll->delete(); - } - } - - // set user to null if user was deleted - Poll::query()->whereNotNull('user_id')->doesntHave('user')->update([ - 'user_id' => null, - ]); - - $schema->table('polls', function (Blueprint $table) { - $table->integer('discussion_id')->unsigned()->change(); - $table->integer('user_id')->unsigned()->nullable()->change(); - - $table->foreign('discussion_id')->references('id')->on('discussions')->onDelete('cascade'); - $table->foreign('user_id')->references('id')->on('users')->onDelete('set null'); - }); - }, - function (Builder $schema) { - $schema->dropIfExists('polls'); - } -); + $table->foreign('discussion_id')->references('id')->on('discussions')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('set null'); +}); diff --git a/migrations/2019_07_01_000001_add_poll_options_table.php b/migrations/2019_07_01_000001_add_poll_options_table.php index a2c31c6d..439f2813 100755 --- a/migrations/2019_07_01_000001_add_poll_options_table.php +++ b/migrations/2019_07_01_000001_add_poll_options_table.php @@ -9,38 +9,17 @@ * file that was distributed with this source code. */ -use FoF\Polls\Migrations\AbstractMigration; -use FoF\Polls\PollOption; +use Flarum\Database\Migration; use Illuminate\Database\Schema\Blueprint; -use Illuminate\Database\Schema\Builder; -return AbstractMigration::make( - function (Builder $schema) { - $schema->create('poll_options', function (Blueprint $table) { - $table->increments('id'); +return Migration::createTable('poll_options', function (Blueprint $table) { + $table->increments('id'); - $table->string('answer'); + $table->string('answer'); - $table->integer('poll_id')->unsigned(); + $table->integer('poll_id')->unsigned(); - $table->timestamps(); + $table->timestamps(); - $table->foreign('poll_id')->references('id')->on('polls')->onDelete('cascade'); - }); - }, - function (Builder $schema) { - // delete poll options that don't have a poll - PollOption::query()->doesntHave('poll')->delete(); - - $schema->table('poll_options', function (Blueprint $table) { - $table->dropForeign(['poll_id']); - }); - - $schema->table('poll_options', function (Blueprint $table) { - $table->foreign('poll_id')->references('id')->on('polls')->onDelete('cascade'); - }); - }, - function (Builder $schema) { - $schema->dropIfExists('poll_options'); - } -); + $table->foreign('poll_id')->references('id')->on('polls')->onDelete('cascade'); +}); diff --git a/migrations/2019_07_01_000002_add_poll_votes_table.php b/migrations/2019_07_01_000002_add_poll_votes_table.php index aa194f4f..dd129652 100755 --- a/migrations/2019_07_01_000002_add_poll_votes_table.php +++ b/migrations/2019_07_01_000002_add_poll_votes_table.php @@ -9,41 +9,19 @@ * file that was distributed with this source code. */ -use FoF\Polls\Migrations\AbstractMigration; -use FoF\Polls\PollVote; +use Flarum\Database\Migration; use Illuminate\Database\Schema\Blueprint; -use Illuminate\Database\Schema\Builder; -return AbstractMigration::make( - function (Builder $schema) { - $schema->create('poll_votes', function (Blueprint $table) { - $table->increments('id'); +return Migration::createTable('poll_votes', function (Blueprint $table) { + $table->increments('id'); - $table->integer('poll_id')->unsigned(); - $table->integer('option_id')->unsigned(); - $table->integer('user_id')->unsigned()->nullable(); + $table->integer('poll_id')->unsigned(); + $table->integer('option_id')->unsigned(); + $table->integer('user_id')->unsigned()->nullable(); - $table->timestamps(); + $table->timestamps(); - $table->foreign('poll_id')->references('id')->on('polls')->onDelete('cascade'); - $table->foreign('option_id')->references('id')->on('poll_options')->onDelete('cascade'); - $table->foreign('user_id')->references('id')->on('users')->onDelete('set null'); - }); - }, - function (Builder $schema) { - // delete votes that don't have a poll or option - PollVote::query()->doesntHave('poll')->orDoesntHave('option')->delete(); - - $schema->table('poll_votes', function (Blueprint $table) { - $table->dropForeign(['option_id']); - }); - - $schema->table('poll_votes', function (Blueprint $table) { - $table->foreign('poll_id')->references('id')->on('polls')->onDelete('cascade'); - $table->foreign('option_id')->references('id')->on('poll_options')->onDelete('cascade'); - }); - }, - function (Builder $schema) { - $schema->dropIfExists('poll_votes'); - } -); + $table->foreign('poll_id')->references('id')->on('polls')->onDelete('cascade'); + $table->foreign('option_id')->references('id')->on('poll_options')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('set null'); +}); diff --git a/resources/less/forum.less b/resources/less/forum.less index 723836a0..0cafa149 100755 --- a/resources/less/forum.less +++ b/resources/less/forum.less @@ -81,14 +81,6 @@ } } - .Checkbox { - &.off { - .Checkbox-display { - background: darken(@control-bg, 5%); - } - } - } - .PollModal-SubmitButton { margin-top: 10px; } @@ -99,6 +91,10 @@ } .Post-poll { + .Post--hidden & { + opacity: 0.5; + } + .PollOptions { display: grid; grid: auto-flow / repeat(auto-fit, minmax(~"min(250px, 100%)", 1fr)); @@ -121,8 +117,13 @@ flex-grow: 1; } + &-voters { + .Button--color-auto('button-primary'); + } + .Button { flex-shrink: 1; + padding: 6px 10px; .icon { margin-right: 0; @@ -141,8 +142,11 @@ width: var(--width, 0); background-color: @muted-color; - animation: slideIn 1s ease-in-out; - transition: width 0.75s ease-in-out; + // Don't animate if the post is hidden + &:not(.Post--hidden &) { + animation: slideIn 1s ease-in-out; + transition: width 0.75s ease-in-out; + } .checkmark { background-color: #fabc67; @@ -254,6 +258,8 @@ -ms-transform: rotate(45deg); transform: rotate(45deg); } + + } } } diff --git a/resources/locale/en.yml b/resources/locale/en.yml index 71907a02..9abb2de2 100755 --- a/resources/locale/en.yml +++ b/resources/locale/en.yml @@ -5,7 +5,8 @@ fof-polls: permissions: view_results_without_voting: View results without voting start: Start a poll - self_edit: Allow users to edit their own polls + self_edit: Edit created polls (requires post edit permission) + self_post_edit: Edit *all* polls on own posts (requires post edit permission) vote: Vote on polls change_vote: Change vote moderate: Edit & remove polls @@ -18,7 +19,7 @@ fof-polls: max_votes_allowed: "Poll allows voting for {max, plural, one {# option} other {# options}}." composer_discussion: - add_poll: Add Poll + add_poll: => fof-polls.forum.moderation.add edit_poll: => fof-polls.forum.moderation.edit no_permission_alert: You do not have permission to start a poll. @@ -42,6 +43,7 @@ fof-polls: submit: Submit moderation: + add: Add Poll delete: Remove Poll delete_confirm: Are you sure you want to delete this poll? edit: Edit Poll diff --git a/src/Access/PollPolicy.php b/src/Access/PollPolicy.php index 85ec07a2..e51bc44b 100644 --- a/src/Access/PollPolicy.php +++ b/src/Access/PollPolicy.php @@ -24,13 +24,20 @@ public function seeVoteCount(User $actor, Poll $poll) } } - public function seeVotes(User $actor, Poll $poll) + public function seeVoters(User $actor, Poll $poll) { if (($poll->myVotes($actor)->count() || $actor->can('polls.viewResultsWithoutVoting', $poll->post->discussion)) && $poll->public_poll) { return $this->allow(); } } + public function view(User $actor, Poll $poll) + { + if ($actor->can('view', $poll->post)) { + return $this->allow(); + } + } + public function vote(User $actor, Poll $poll) { if ($actor->can('polls.vote', $poll->post->discussion) && !$poll->hasEnded()) { @@ -51,10 +58,10 @@ public function edit(User $actor, Poll $poll) return $this->allow(); } - if ($actor->hasPermission('polls.selfEdit') && !$poll->hasEnded()) { - $ownerId = $poll->post->user_id; - - if ($ownerId && $ownerId === $actor->id) { + if (!$poll->hasEnded() && $actor->can('edit', $poll->post)) { + // User either created poll & can edit own poll or can edit all polls in post + if (($actor->id === $poll->user_id && $actor->hasPermission('polls.selfEdit')) + || ($actor->id == $poll->post->user_id && $actor->hasPermission('polls.selfPostEdit'))) { return $this->allow(); } } diff --git a/src/Access/PostPolicy.php b/src/Access/PostPolicy.php new file mode 100644 index 00000000..1a9ad07d --- /dev/null +++ b/src/Access/PostPolicy.php @@ -0,0 +1,35 @@ +type, static::$ALLOWED_POST_TYPES)) { + return $this->deny(); + } + + // Only allow polls to be started if the user can start polls in the discussion + // and if the user can either edit the post or is currently creating a new post. + // For example, actors cannot 'edit' a post they're currently creating if post editing is allowed until next reply. + if ($actor->can('polls.start', $post->discussion) && (!$post->exists || $actor->can('edit', $post))) { + return $this->allow(); + } + } +} diff --git a/src/Access/ScopePollVisibility.php b/src/Access/ScopePollVisibility.php new file mode 100644 index 00000000..ea9f2442 --- /dev/null +++ b/src/Access/ScopePollVisibility.php @@ -0,0 +1,29 @@ +whereExists(function ($query) use ($actor) { + $query->selectRaw('1') + ->from('posts') + ->whereColumn('posts.id', 'polls.post_id'); + Post::query()->setQuery($query)->whereVisibleTo($actor); + }); + } +} diff --git a/src/Api/Controllers/VotePollController.php b/src/Api/Controllers/CreatePollController.php old mode 100755 new mode 100644 similarity index 50% rename from src/Api/Controllers/VotePollController.php rename to src/Api/Controllers/CreatePollController.php index b72cd20d..d313b435 --- a/src/Api/Controllers/VotePollController.php +++ b/src/Api/Controllers/CreatePollController.php @@ -11,54 +11,47 @@ namespace FoF\Polls\Api\Controllers; -use Flarum\Api\Controller\AbstractShowController; +use Flarum\Api\Controller\AbstractCreateController; +use Flarum\Bus\Dispatcher; use Flarum\Http\RequestUtil; +use Flarum\Post\PostRepository; use FoF\Polls\Api\Serializers\PollSerializer; -use FoF\Polls\Commands\VotePoll; -use Illuminate\Contracts\Bus\Dispatcher; +use FoF\Polls\Commands\CreatePoll; use Illuminate\Support\Arr; use Psr\Http\Message\ServerRequestInterface; use Tobscure\JsonApi\Document; -class VotePollController extends AbstractShowController +class CreatePollController extends AbstractCreateController { - /** - * @var string - */ public $serializer = PollSerializer::class; - public $include = ['options', 'myVotes', 'myVotes.option']; + public $include = ['options']; - public $optionalInclude = ['votes', 'votes.option', 'votes.user']; + /** + * @var PostRepository + */ + protected $posts; /** * @var Dispatcher */ protected $bus; - /** - * @param Dispatcher $bus - */ - public function __construct(Dispatcher $bus) + public function __construct(PostRepository $posts, Dispatcher $bus) { + $this->posts = $posts; $this->bus = $bus; } - /** - * Get the data to be serialized and assigned to the response document. - * - * @param ServerRequestInterface $request - * @param Document $document - * - * @return mixed - */ protected function data(ServerRequestInterface $request, Document $document) { + $postId = Arr::get($request->getParsedBody(), 'data.relationships.post.data.id'); + return $this->bus->dispatch( - new VotePoll( + new CreatePoll( RequestUtil::getActor($request), - Arr::get($request->getQueryParams(), 'id'), - Arr::get($request->getParsedBody(), 'data', []) + $this->posts->findOrFail($postId), + Arr::get($request->getParsedBody(), 'data.attributes') ) ); } diff --git a/src/Api/Controllers/ShowPollController.php b/src/Api/Controllers/ShowPollController.php index 39ed0775..0001b8e9 100644 --- a/src/Api/Controllers/ShowPollController.php +++ b/src/Api/Controllers/ShowPollController.php @@ -12,8 +12,9 @@ namespace FoF\Polls\Api\Controllers; use Flarum\Api\Controller\AbstractShowController; +use Flarum\Http\RequestUtil; use FoF\Polls\Api\Serializers\PollSerializer; -use FoF\Polls\Poll; +use FoF\Polls\PollRepository; use Illuminate\Support\Arr; use Psr\Http\Message\ServerRequestInterface; use Tobscure\JsonApi\Document; @@ -29,11 +30,24 @@ class ShowPollController extends AbstractShowController public $optionalInclude = ['votes', 'votes.option', 'votes.user']; + /** + * @var PollRepository + */ + protected $polls; + + public function __construct(PollRepository $polls) + { + $this->polls = $polls; + } + /** * {@inheritdoc} */ protected function data(ServerRequestInterface $request, Document $document) { - return Poll::findOrFail(Arr::get($request->getQueryParams(), 'id')); + return $this->polls->findOrFail( + Arr::get($request->getQueryParams(), 'id'), + RequestUtil::getActor($request) + ); } } diff --git a/src/Api/Serializers/PollSerializer.php b/src/Api/Serializers/PollSerializer.php index 6665e10f..95207fe5 100755 --- a/src/Api/Serializers/PollSerializer.php +++ b/src/Api/Serializers/PollSerializer.php @@ -42,7 +42,7 @@ protected function getDefaultAttributes($poll) 'canVote' => $this->actor->can('vote', $poll), 'canEdit' => $this->actor->can('edit', $poll), 'canDelete' => $this->actor->can('delete', $poll), - 'canSeeVotes' => $this->actor->can('seeVotes', $poll), + 'canSeeVoters' => $this->actor->can('seeVoters', $poll), 'canChangeVote' => $this->actor->can('changeVote', $poll), ]; @@ -63,7 +63,7 @@ public function options($model) public function votes($model) { - if ($this->actor->cannot('seeVotes', $model)) { + if ($this->actor->cannot('seeVoters', $model)) { return null; } diff --git a/src/Commands/CreatePoll.php b/src/Commands/CreatePoll.php new file mode 100644 index 00000000..b56e2ee3 --- /dev/null +++ b/src/Commands/CreatePoll.php @@ -0,0 +1,54 @@ +actor = $actor; + $this->post = $post; + $this->data = $data; + $this->savePollOn = $savePollOn ?: function (callable $callback) { + return $callback(); + }; + } +} diff --git a/src/Commands/CreatePollHandler.php b/src/Commands/CreatePollHandler.php new file mode 100644 index 00000000..9f4a51f5 --- /dev/null +++ b/src/Commands/CreatePollHandler.php @@ -0,0 +1,130 @@ +validator = $validator; + $this->optionValidator = $optionValidator; + $this->events = $events; + $this->settings = $settings; + $this->posts = $posts; + } + + public function handle(CreatePoll $command) + { + $command->actor->assertCan('startPoll', $command->post); + + $attributes = $command->data; + + // Ideally we would use some JSON:API relationship syntax, but it's just too complicated with Flarum to generate the correct JSON payload + // Instead we just pass an array of option objects that are each a set of key-value pairs for the option attributes + // This is also the same syntax that always used by EditPollHandler + $rawOptionsData = Arr::get($attributes, 'options'); + $optionsData = []; + + if (is_array($rawOptionsData)) { + foreach ($rawOptionsData as $rawOptionData) { + $optionsData[] = [ + 'answer' => Arr::get($rawOptionData, 'answer'), + 'imageUrl' => Arr::get($rawOptionData, 'imageUrl') ?: null, + ]; + } + } + + $this->validator->assertValid($attributes); + + foreach ($optionsData as $optionData) { + // It is guaranteed all keys exist in the array because $optionData is manually created above + // This ensures every attribute will be validated (Flarum doesn't validate missing keys) + $this->optionValidator->assertValid($optionData); + } + + return ($command->savePollOn)(function () use ($optionsData, $attributes, $command) { + $endDate = Arr::get($attributes, 'endDate'); + $carbonDate = Carbon::parse($endDate); + + if (!$carbonDate->isFuture()) { + $carbonDate = null; + } + + $poll = Poll::build( + Arr::get($attributes, 'question'), + $command->post->id, + $command->actor->id, + $carbonDate != null ? $carbonDate->utc() : null, + Arr::get($attributes, 'publicPoll'), + Arr::get($attributes, 'allowMultipleVotes'), + Arr::get($attributes, 'maxVotes') + ); + + $this->events->dispatch(new SavingPollAttributes($command->actor, $poll, $attributes, $attributes)); + + $poll->save(); + + $this->events->dispatch(new PollWasCreated($command->actor, $poll)); + + foreach ($optionsData as $optionData) { + $imageUrl = Arr::get($optionData, 'imageUrl'); + + if (!$this->settings->get('fof-polls.allowOptionImage')) { + $imageUrl = null; + } + + $option = PollOption::build(Arr::get($optionData, 'answer'), $imageUrl); + + $poll->options()->save($option); + } + + return $poll; + }); + } +} diff --git a/src/Commands/DeletePollHandler.php b/src/Commands/DeletePollHandler.php index a01fdd88..2a13be33 100755 --- a/src/Commands/DeletePollHandler.php +++ b/src/Commands/DeletePollHandler.php @@ -11,16 +11,23 @@ namespace FoF\Polls\Commands; -use FoF\Polls\Poll; +use FoF\Polls\PollRepository; class DeletePollHandler { + /** + * @var PollRepository + */ + protected $polls; + + public function __construct(PollRepository $polls) + { + $this->polls = $polls; + } + public function handle(DeletePoll $command) { - /** - * @var $poll Poll - */ - $poll = Poll::findOrFail($command->pollId); + $poll = $this->polls->findOrFail($command->pollId, $command->actor); $command->actor->assertCan('delete', $poll); diff --git a/src/Commands/EditPollHandler.php b/src/Commands/EditPollHandler.php index 42441c57..4bf815ba 100755 --- a/src/Commands/EditPollHandler.php +++ b/src/Commands/EditPollHandler.php @@ -14,7 +14,7 @@ use Carbon\Carbon; use Flarum\Settings\SettingsRepositoryInterface; use FoF\Polls\Events\SavingPollAttributes; -use FoF\Polls\Poll; +use FoF\Polls\PollRepository; use FoF\Polls\Validators\PollOptionValidator; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Support\Arr; @@ -36,19 +36,22 @@ class EditPollHandler */ protected $settings; - public function __construct(PollOptionValidator $optionValidator, Dispatcher $events, SettingsRepositoryInterface $settings) + /** + * @var PollRepository + */ + protected $polls; + + public function __construct(PollRepository $polls, PollOptionValidator $optionValidator, Dispatcher $events, SettingsRepositoryInterface $settings) { $this->optionValidator = $optionValidator; $this->events = $events; $this->settings = $settings; + $this->polls = $polls; } public function handle(EditPoll $command) { - /** - * @var $poll Poll - */ - $poll = Poll::findOrFail($command->pollId); + $poll = $this->polls->findOrFail($command->pollId, $command->actor); $command->actor->assertCan('edit', $poll); diff --git a/src/Commands/MultipleVotesPollHandler.php b/src/Commands/MultipleVotesPollHandler.php index 026121f2..a16d6c54 100644 --- a/src/Commands/MultipleVotesPollHandler.php +++ b/src/Commands/MultipleVotesPollHandler.php @@ -18,7 +18,7 @@ use FoF\Polls\Events\PollVotesChanged; use FoF\Polls\Events\PollWasVoted; use FoF\Polls\Poll; -use FoF\Polls\PollVote; +use FoF\Polls\PollRepository; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\ConnectionResolverInterface; @@ -53,13 +53,19 @@ class MultipleVotesPollHandler */ private $db; + /** + * @var PollRepository + */ + private $polls; + /** * @param Dispatcher $events * @param SettingsRepositoryInterface $settings * @param Container $container */ - public function __construct(Dispatcher $events, SettingsRepositoryInterface $settings, Container $container, Factory $validation, ConnectionResolverInterface $db) + public function __construct(PollRepository $polls, Dispatcher $events, SettingsRepositoryInterface $settings, Container $container, Factory $validation, ConnectionResolverInterface $db) { + $this->polls = $polls; $this->events = $events; $this->settings = $settings; $this->container = $container; @@ -74,12 +80,10 @@ public function __construct(Dispatcher $events, SettingsRepositoryInterface $set public function handle(MultipleVotesPoll $command) { $actor = $command->actor; - /** - * @var $poll Poll - */ - $poll = Poll::findOrFail($command->pollId); $data = $command->data; + $poll = $this->polls->findOrFail($command->pollId, $actor); + $actor->assertCan('vote', $poll); $optionIds = Arr::get($data, 'optionIds'); @@ -137,8 +141,6 @@ function ($attribute, $value, $fail) use ($options) { ]); $myVotes->push($vote); - - $this->pushNewVote($vote); }); // Update vote counts of options & poll @@ -179,20 +181,6 @@ function ($attribute, $value, $fail) use ($options) { return $poll; } - /** - * Pushes a new vote through websocket. Kept for backward compatibility, but we are no longer using it. - * - * @param PollVote $vote - * - * @throws \Pusher\PusherException - */ - public function pushNewVote($vote) - { - if ($pusher = $this->getPusher()) { - $pusher->trigger('public', 'newPollVote', $vote); - } - } - /** * Pushes an updated option through websocket. * diff --git a/src/Commands/VotePoll.php b/src/Commands/VotePoll.php deleted file mode 100755 index ae2740b2..00000000 --- a/src/Commands/VotePoll.php +++ /dev/null @@ -1,44 +0,0 @@ -actor = $actor; - $this->pollId = $pollId; - $this->data = $data; - } -} diff --git a/src/Commands/VotePollHandler.php b/src/Commands/VotePollHandler.php deleted file mode 100755 index 1dc53d6d..00000000 --- a/src/Commands/VotePollHandler.php +++ /dev/null @@ -1,168 +0,0 @@ -events = $events; - $this->settings = $settings; - $this->container = $container; - } - - public function handle(VotePoll $command) - { - $actor = $command->actor; - /** - * @var $poll Poll - */ - $poll = Poll::findOrFail($command->pollId); - $data = $command->data; - - $optionId = Arr::get($data, 'optionId'); - - $actor->assertCan('vote', $poll); - - /** - * @var $vote PollVote|null - */ - $vote = $poll->votes()->where('user_id', $actor->id)->first(); - - if ($vote) { - $actor->assertCan('changeVote', $poll); - - if ($poll->allow_multiple_votes) { - throw new PermissionDeniedException(); // TODO change to proper error - } - } - - $previousOption = null; - - if ($optionId === null && $vote !== null) { - $previousOption = $vote->option; - - $vote->delete(); - $vote = null; - } elseif ($optionId !== null) { - if ($vote) { - $previousOption = $vote->option; - - $vote->option_id = $optionId; - $vote->save(); - } else { - $vote = $poll->votes()->create([ - 'user_id' => $actor->id, - 'option_id' => $optionId, - ]); - } - - // Forget the relation in case it was loaded for $previousOption - $vote->unsetRelation('option'); - - $vote->option->refreshVoteCount()->save(); - - $this->events->dispatch(new PollWasVoted($actor, $poll, $vote, $vote !== null)); - - $this->pushNewVote($vote); - } - - $poll->refreshVoteCount()->save(); - - if ($previousOption) { - $previousOption->refreshVoteCount()->save(); - - $this->pushUpdatedOption($previousOption); - } - - if ($vote) { - $this->pushUpdatedOption($vote->option); - } - - return $poll; - } - - /** - * Pushes a new vote through websocket. Kept for backward compatibility, but we are no longer using it. - * - * @param PollVote $vote - * - * @throws \Pusher\PusherException - */ - public function pushNewVote($vote) - { - if ($pusher = $this->getPusher()) { - $pusher->trigger('public', 'newPollVote', $vote); - } - } - - /** - * Pushes an updated option through websocket. - * - * @param PollOption $option - * - * @throws \Pusher\PusherException - */ - public function pushUpdatedOption(PollOption $option) - { - if ($pusher = $this->getPusher()) { - $pusher->trigger('public', 'updatedPollOptions', [ - 'pollId' => $option->poll->id, - 'pollVoteCount' => $option->poll->vote_count, - 'options' => [ - [ - 'id' => $option->id, - 'voteCount' => $option->vote_count, - ], - ], - ]); - } - } - - private function getPusher() - { - return MultipleVotesPollHandler::pusher($this->container, $this->settings); - } -} diff --git a/src/Listeners/SavePollsToDatabase.php b/src/Listeners/SavePollsToDatabase.php index 9a5feea4..18df2a96 100755 --- a/src/Listeners/SavePollsToDatabase.php +++ b/src/Listeners/SavePollsToDatabase.php @@ -11,18 +11,12 @@ namespace FoF\Polls\Listeners; -use Carbon\Carbon; use Flarum\Foundation\ValidationException; use Flarum\Post\Event\Saving; -use Flarum\Settings\SettingsRepositoryInterface; -use FoF\Polls\Events\PollWasCreated; -use FoF\Polls\Events\SavingPollAttributes; -use FoF\Polls\Poll; -use FoF\Polls\PollOption; +use FoF\Polls\Commands\CreatePoll; use FoF\Polls\Validators\PollOptionValidator; use FoF\Polls\Validators\PollValidator; use Illuminate\Contracts\Events\Dispatcher; -use Illuminate\Support\Arr; use Symfony\Contracts\Translation\TranslatorInterface; class SavePollsToDatabase @@ -43,16 +37,16 @@ class SavePollsToDatabase protected $events; /** - * @var SettingsRepositoryInterface + * @var \Flarum\Bus\Dispatcher */ - protected $settings; + protected $bus; - public function __construct(PollValidator $validator, PollOptionValidator $optionValidator, Dispatcher $events, SettingsRepositoryInterface $settings) + public function __construct(PollValidator $validator, PollOptionValidator $optionValidator, Dispatcher $events, \Flarum\Bus\Dispatcher $bus) { $this->validator = $validator; $this->optionValidator = $optionValidator; $this->events = $events; - $this->settings = $settings; + $this->bus = $bus; } public function handle(Saving $event) @@ -63,7 +57,7 @@ public function handle(Saving $event) // 'assertCan' throws a generic no permission error, but we want to be more specific. // There are a lot of different reasons why a user might not be able to post a discussion. - if ($event->actor->cannot('polls.start', $event->post->discussion)) { + if ($event->actor->cannot('startPoll', $event->post)) { $translator = resolve(TranslatorInterface::class); throw new ValidationException([ @@ -73,73 +67,15 @@ public function handle(Saving $event) $attributes = (array) $event->data['attributes']['poll']; - // Ideally we would use some JSON:API relationship syntax, but it's just too complicated with Flarum to generate the correct JSON payload - // Instead we just pass an array of option objects that are each a set of key-value pairs for the option attributes - // This is also the same syntax that always used by EditPollHandler - $rawOptionsData = Arr::get($attributes, 'options'); - - $optionsData = []; - - if (is_array($rawOptionsData)) { - foreach ($rawOptionsData as $rawOptionData) { - $optionsData[] = [ - 'answer' => Arr::get($rawOptionData, 'answer'), - 'imageUrl' => Arr::get($rawOptionData, 'imageUrl') ?: null, - ]; - } - } else { - // Backward-compatibility with old syntax that only passed an array of strings - // We are no longer using this syntax in the extension itself - foreach ((array) Arr::get($attributes, 'relationships.options') as $answerText) { - $optionsData[] = [ - 'answer' => (string) $answerText, - ]; - } - } - - $this->validator->assertValid($attributes); - - foreach ($optionsData as $optionData) { - // It is guaranteed all keys exist in the array because $optionData is manually created above - // This ensures every attribute will be validated (Flarum doesn't validate missing keys) - $this->optionValidator->assertValid($optionData); - } - - $event->post->afterSave(function ($post) use ($optionsData, $attributes, $event) { - $endDate = Arr::get($attributes, 'endDate'); - $carbonDate = Carbon::parse($endDate); - - if (!$carbonDate->isFuture()) { - $carbonDate = null; - } - - $poll = Poll::build( - Arr::get($attributes, 'question'), - $post->id, - $event->actor->id, - $carbonDate != null ? $carbonDate->utc() : null, - Arr::get($attributes, 'publicPoll'), - Arr::get($attributes, 'allowMultipleVotes'), - Arr::get($attributes, 'maxVotes') - ); - - $this->events->dispatch(new SavingPollAttributes($event->actor, $poll, $attributes, $event->data)); - - $poll->save(); - - $this->events->dispatch(new PollWasCreated($event->actor, $poll)); - - foreach ($optionsData as $optionData) { - $imageUrl = Arr::get($optionData, 'imageUrl'); - - if (!$this->settings->get('fof-polls.allowOptionImage')) { - $imageUrl = null; + $this->bus->dispatch( + new CreatePoll( + $event->actor, + $event->post, + $attributes, + function (callable $callback) use ($event) { + $event->post->afterSave($callback); } - - $option = PollOption::build(Arr::get($optionData, 'answer'), $imageUrl); - - $poll->options()->save($option); - } - }); + ) + ); } } diff --git a/src/Migrations/AbstractMigration.php b/src/Migrations/AbstractMigration.php deleted file mode 100755 index 5f5115a7..00000000 --- a/src/Migrations/AbstractMigration.php +++ /dev/null @@ -1,51 +0,0 @@ - function (Builder $schema) use ($existing, $up) { - if ($schema->getConnection()->table('migrations')->where('extension', 'reflar-polls')->exists()) { - $migrated = $schema->getConnection()->table('migrations') - ->where('migration', '2019_06_29_000000_add_support_for_deleted_users') - ->where('extension', 'reflar-polls') - ->exists(); - - if (!$migrated) { - throw new \UnexpectedValueException('[fof/polls] Please run the latest migration(s) of reflar/polls before enabling this extension.'); - } - - if ($existing != null) { - $existing($schema); - } - - return; - } - - $up($schema); - }, - 'down' => $down, - ]; - } -} diff --git a/src/Poll.php b/src/Poll.php index 088bc113..17e158bb 100755 --- a/src/Poll.php +++ b/src/Poll.php @@ -12,6 +12,7 @@ namespace FoF\Polls; use Flarum\Database\AbstractModel; +use Flarum\Database\ScopeVisibilityTrait; use Flarum\Post\Post; use Flarum\User\User; use Illuminate\Database\Eloquent\Collection; @@ -35,6 +36,8 @@ */ class Poll extends AbstractModel { + use ScopeVisibilityTrait; + /** * {@inheritdoc} */ diff --git a/src/PollRepository.php b/src/PollRepository.php new file mode 100644 index 00000000..2118e506 --- /dev/null +++ b/src/PollRepository.php @@ -0,0 +1,50 @@ + + */ + public function queryVisibleTo(?User $user = null): Builder + { + $query = $this->query(); + + if ($user !== null) { + $query->whereVisibleTo($user); + } + + return $query; + } + + /** + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + */ + public function findOrFail($id, User $actor = null): Poll + { + return $this->queryVisibleTo($actor)->findOrFail($id); + } +}