From a1d797d9d29839dd34f9e4be516512fa17957904 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 26 Feb 2024 19:43:49 -0800 Subject: [PATCH 01/18] Proposals --- api/graphql/index.js | 17 +++- api/graphql/makeModels.js | 22 ++--- api/graphql/mutations/index.js | 5 +- api/graphql/mutations/post.js | 84 +++++++++++++--- api/graphql/mutations/post.test.js | 100 ++++++++++++++++++- api/graphql/schema.graphql | 59 ++++++++---- api/models/Post.js | 74 ++++++++++++-- api/models/ProposalOption.js | 10 ++ api/models/ProposalVote.js | 17 ++++ migrations/20240218134105_proposals.js | 47 +++++++++ migrations/schema.sql | 127 ++++++++++++++++++++++++- package.json | 2 + test/setup/factories.js | 11 +++ 13 files changed, 516 insertions(+), 59 deletions(-) create mode 100644 api/models/ProposalOption.js create mode 100644 api/models/ProposalVote.js create mode 100644 migrations/20240218134105_proposals.js diff --git a/api/graphql/index.js b/api/graphql/index.js index 64ff918b7..e27886ab4 100644 --- a/api/graphql/index.js +++ b/api/graphql/index.js @@ -12,6 +12,7 @@ import { addModerator, addPeopleToProjectRole, addPostToCollection, + addProposalVote, addRoleToMember, addSkill, addSkillToLearn, @@ -79,6 +80,7 @@ import { removePost, removePostFromCollection, removeRoleFromMember, + removeProposalVote, removeSkill, removeSkillToLearn, removeSuggestedSkillFromGroup, @@ -86,7 +88,9 @@ import { respondToEvent, sendEmailVerification, sendPasswordReset, + setProposalOptions, subscribe, + swapProposalVote, unblockUser, unfulfillPost, unlinkAccount, @@ -101,8 +105,7 @@ import { updateStripeAccount, updateWidget, useInvitation, - verifyEmail, - vote + verifyEmail } from './mutations' import InvitationService from '../services/InvitationService' import makeModels from './makeModels' @@ -302,6 +305,8 @@ export function makeMutations (expressContext, userId, isAdmin, fetchOne) { addPostToCollection: (root, { collectionId, postId }) => addPostToCollection(userId, collectionId, postId), + addProposalVote: (root, { postId, optionId }) => addProposalVote({ userId, postId, optionId }), + addRoleToMember: (root, { personId, groupRoleId, groupId }) => addRoleToMember({ userId, personId, groupRoleId, groupId }), addSkill: (root, { name }) => addSkill(userId, name), @@ -438,6 +443,8 @@ export function makeMutations (expressContext, userId, isAdmin, fetchOne) { removePostFromCollection: (root, { collectionId, postId }) => removePostFromCollection(userId, collectionId, postId), + removeProposalVote: (root, { postId, optionId }) => removeProposalVote({ userId, postId, optionId }), + removeRoleFromMember: (root, { groupRoleId, personId, groupId }) => removeRoleFromMember({ groupRoleId, personId, userId, groupId }), removeSkill: (root, { id, name }) => removeSkill(userId, id || name), @@ -456,9 +463,13 @@ export function makeMutations (expressContext, userId, isAdmin, fetchOne) { respondToEvent: (root, { id, response }) => respondToEvent(userId, id, response), + setProposalOptions: (root, { postId, options }) => setProposalOptions({ userId, postId, options }), + subscribe: (root, { groupId, topicId, isSubscribing }) => subscribe(userId, topicId, groupId, isSubscribing), + swapProposalVote: (root, { postId, removeOptionId, addOptionId }) => swapProposalVote({ userId, postId, removeOptionId, addOptionId }), + unblockUser: (root, { blockedUserId }) => unblockUser(userId, blockedUserId), unfulfillPost: (root, { postId }) => unfulfillPost(userId, postId), @@ -489,8 +500,6 @@ export function makeMutations (expressContext, userId, isAdmin, fetchOne) { useInvitation: (root, { invitationToken, accessCode }) => useInvitation(userId, invitationToken, accessCode), - - vote: (root, { postId, isUpvote }) => vote(userId, postId, isUpvote) } } diff --git a/api/graphql/makeModels.js b/api/graphql/makeModels.js index 1e2fa4b20..a19931d73 100644 --- a/api/graphql/makeModels.js +++ b/api/graphql/makeModels.js @@ -188,6 +188,7 @@ export default function makeModels (userId, isAdmin, apiClient) { attributes: [ 'accept_contributions', 'announcement', + 'anonymity', 'commentsTotal', 'created_at', 'donations_link', @@ -197,6 +198,9 @@ export default function makeModels (userId, isAdmin, apiClient) { 'link_preview_featured', 'location', 'project_management_link', + 'proposal_status', + 'proposal_outcome', + 'quorum', 'reactions_summary', 'start_time', 'timezone', @@ -208,7 +212,6 @@ export default function makeModels (userId, isAdmin, apiClient) { commentersTotal: p => p.getCommentersTotal(userId), details: p => p.details(userId), myReactions: p => userId ? p.reactionsForUser(userId).fetch() : [], - myVote: p => userId ? p.userVote(userId).then(v => !!v) : false, // Remove once Mobile has been updated myEventResponse: p => userId && p.isEvent() ? p.userEventInvitation(userId).then(eventInvitation => eventInvitation ? eventInvitation.get('response') : '') @@ -222,6 +225,8 @@ export default function makeModels (userId, isAdmin, apiClient) { 'locationObject', { members: { querySet: true } }, { eventInvitations: { querySet: true } }, + { proposalOptions: { querySet: true } }, + { proposalVotes: { querySet: true } }, 'linkPreview', 'postMemberships', { @@ -258,6 +263,8 @@ export default function makeModels (userId, isAdmin, apiClient) { mentionsOf, offset, order, + proposalOutcome, + proposalStatus, sortBy, search, topic, @@ -283,6 +290,8 @@ export default function makeModels (userId, isAdmin, apiClient) { onlyMyGroups: context === 'all', onlyPublic: context === 'public', order, + proposalOutcome, + proposalStatus, sort: sortBy, term: search, topic, @@ -812,17 +821,6 @@ export default function makeModels (userId, isAdmin, apiClient) { ], filter: nonAdminFilter(reactionFilter('reactions', userId)) }, - Vote: { // TO BE REMOVED ONCE MOBILE IS UPDATED - model: Reaction, - getters: { - createdAt: v => v.get('date_reacted') - }, - relations: [ - 'post', - { user: { alias: 'voter' } } - ], - filter: nonAdminFilter(reactionFilter('reactions', userId)) - }, GroupTopic: { model: GroupTag, diff --git a/api/graphql/mutations/index.js b/api/graphql/mutations/index.js index c7707b28b..26b4f9dd0 100644 --- a/api/graphql/mutations/index.js +++ b/api/graphql/mutations/index.js @@ -63,8 +63,11 @@ export { createPost, fulfillPost, unfulfillPost, + setProposalOptions, + addProposalVote, + removeProposalVote, + swapProposalVote, updatePost, - vote, deletePost, pinPost } from './post' diff --git a/api/graphql/mutations/post.js b/api/graphql/mutations/post.js index 94d283471..2fcd93a7b 100644 --- a/api/graphql/mutations/post.js +++ b/api/graphql/mutations/post.js @@ -10,6 +10,20 @@ export function createPost (userId, data) { .then(validatedData => underlyingCreatePost(userId, validatedData)) } +export function deletePost (userId, postId) { + return Post.find(postId) + .then(post => { + if (!post) { + throw new GraphQLYogaError("Post does not exist") + } + if (post.get('user_id') !== userId) { + throw new GraphQLYogaError("You don't have permission to modify this post") + } + return Post.deactivate(postId) + }) + .then(() => ({success: true})) +} + export function updatePost (userId, { id, data }) { return convertGraphqlPostData(data) .tap(convertedData => validatePostData(userId, convertedData)) @@ -38,22 +52,66 @@ export function unfulfillPost (userId, postId) { .then(() => ({success: true})) } -export function vote (userId, postId, isUpvote) { // TODO: remove after mobile brought back into sync +export async function addProposalVote ({ userId, postId, optionId }) { + if (!userId || !postId || !optionId) throw new GraphQLYogaError(`Missing required parameters: ${JSON.stringify({ userId, postId, optionId })}`) + + const authorized = await Post.isVisibleToUser(postId, userId) + if (!authorized) throw new GraphQLYogaError("You don't have permission to vote on this post") + return Post.find(postId) - .then(post => post.vote(userId, isUpvote)) + .then(post => { + if (post.get('proposal_status') !== Post.Proposal_Status.VOTING) throw new GraphQLYogaError('Cannot vote on a proposal that is in discussion or completed') + return post.addProposalVote({ userId, optionId }) + }) + .catch((err) => { throw new GraphQLYogaError(`adding of vote failed: ${err}`) }) + .then(() => ({ success: true })) } -export function deletePost (userId, postId) { + +export async function removeProposalVote ({ userId, postId, optionId }) { + if (!userId || !postId || !optionId) throw new GraphQLYogaError(`Missing required parameters: ${JSON.stringify({ userId, postId, optionId })}`) + + const authorized = await Post.isVisibleToUser(postId, userId) + if (!authorized) throw new GraphQLYogaError("You don't have permission to vote on this post") + return Post.find(postId) - .then(post => { - if (!post) { - throw new GraphQLYogaError("Post does not exist") - } - if (post.get('user_id') !== userId) { - throw new GraphQLYogaError("You don't have permission to modify this post") - } - return Post.deactivate(postId) - }) - .then(() => ({success: true})) + .then(post => { + if (post.get('proposal_status') !== Post.Proposal_Status.VOTING) throw new GraphQLYogaError('Cannot vote on a proposal that is in discussion or completed') + return post.removeProposalVote({ userId, optionId }) + }) + .catch((err) => { throw new GraphQLYogaError(`removal of vote failed: ${err}`) }) + .then(() => ({ success: true })) +} + +export async function setProposalOptions ({ userId, postId, options }) { + if (!userId || !postId || !options) throw new GraphQLYogaError(`Missing required parameters: ${JSON.stringify({ userId, postId, options })}`) + const authorized = await Post.isVisibleToUser(postId, userId) + if (!authorized) throw new GraphQLYogaError("You don't have permission to modify this post") + return Post.find(postId) + .then(post => { + if (post.get('proposal_status') !== Post.Proposal_Status.DISCUSSION) throw new GraphQLYogaError("Proposal options cannot be changed unless the proposal is in 'discussion'") + return post.setProposalOptions(options) + }) + .catch((err) => { throw new GraphQLYogaError(`setting of options failed: ${err}`) }) + .then(() => ({ success: true })) +} + +export async function swapProposalVote ({ userId, postId, removeOptionId, addOptionId }) { + if (!userId || !postId || !removeOptionId || !addOptionId) throw new GraphQLYogaError(`Missing required parameters: ${JSON.stringify({ userId, postId, removeOptionId, addOptionId })}`) + const authorized = await Post.isVisibleToUser(postId, userId) + if (!authorized) throw new GraphQLYogaError("You don't have permission to vote on this post") + if (removeOptionId === addOptionId) throw new GraphQLYogaError('You cannot swap a vote for the same option') + + const post = await Post.find(postId) + if (!post) throw new GraphQLYogaError(`Couldn't find post for ${postId}`) + if (post.get('proposal_status') !== Post.Proposal_Status.VOTING) throw new GraphQLYogaError('Cannot vote on a proposal that is in discussion or completed') + + try { + await post.removeProposalVote({ userId, optionId: removeOptionId }) + await post.addProposalVote({ userId, optionId: addOptionId }) + return { success: true } + } catch (err) { + throw new GraphQLYogaError(`swap of vote failed: ${err}`) + } } export async function pinPost (userId, postId, groupId) { diff --git a/api/graphql/mutations/post.test.js b/api/graphql/mutations/post.test.js index 61e192477..790ce9bce 100644 --- a/api/graphql/mutations/post.test.js +++ b/api/graphql/mutations/post.test.js @@ -1,6 +1,6 @@ import '../../../test/setup' import factories from '../../../test/setup/factories' -import { pinPost } from './post' +import { pinPost, removeProposalVote, addProposalVote, swapProposalVote, setProposalOptions } from './post' describe('pinPost', () => { var user, group, post @@ -43,3 +43,101 @@ describe('pinPost', () => { .catch(e => expect(e.message).to.match(/Couldn't find postMembership/)) }) }) + +describe('ProposalVote', () => { + var user, post, option1, option2, option3, option4, optionId, optionId2, g1 + + before(function () { + user = factories.user() + post = factories.post() + g1 = factories.group({ active: true }) + return Promise.join(user.save(), post.save(), g1.save()) + .then(() => user.joinGroup(g1)) + .then(() => post.groups().attach(g1.id)) + .then(async () => { + option1 = { post_id: post.id, text: 'option1', description: 'description1' } + option2 = { post_id: post.id, text: 'option2', description: 'description2' } + option3 = { post_id: post.id, text: 'third', description: 'description third' } + option4 = { post_id: post.id, text: 'fourth', description: 'description fourth' } + await post.save({ proposal_status: Post.Proposal_Status.DISCUSSION }, { patch: true }) + + return post.setProposalOptions([option1, option2]) + }) + .then(async (result) => { + const rows = result.filter((res) => (res.command === 'INSERT'))[0].rows + optionId = rows[0].id + optionId2 = rows[1].id + await post.save({ proposal_status: Post.Proposal_Status.VOTING }, { patch: true }) + + return post.addProposalVote({ userId: user.id, optionId }) + }) + }) + + it('adds a vote', () => { + return addProposalVote({ userId: user.id, postId: post.id, optionId }) + .then(() => post.proposalVotes().fetch()) + .then(votes => { + expect(votes.length).to.equal(2) + }) + }) + + it('removes the vote', () => { + return removeProposalVote({ userId: user.id, postId: post.id, optionId }) + .then(() => post.proposalVotes().fetch()) + .then(votes => { + expect(votes.length).to.equal(1) + }) + }) + + it('swaps a vote', () => { + return swapProposalVote({ userId: user.id, postId: post.id, removeOptionId: optionId, addOptionId: optionId2 }) + .then(() => post.proposalVotes().fetch()) + .then(votes => { + expect(parseInt(votes.models[0].attributes.option_id)).to.equal(optionId2) + }) + }) + + it('rejects if user is not authorized', () => { + return addProposalVote({ userId: '777', postId: post.id, optionId }) + .then(() => expect.fail('should reject')) + .catch(e => expect(e).to.match(/You don't have permission to vote on this post/)) + }) + + it('allows the proposal options to be updated', async () => { + await removeProposalVote({ userId: user.id, postId: post.id, optionId: optionId2 }) + await post.save({ proposal_status: Post.Proposal_Status.DISCUSSION }, { patch: true }) + return setProposalOptions({ userId: user.id, postId: post.id, options: [option3, option4] }) + .then(() => post.proposalOptions().fetch()) + .then(options => { + expect(options.models[0].attributes.text).to.equal(option3.text) + }) + }) + + it('does not allow proposal options to be updated if the proposal_status is not "discussion"', async () => { + await post.save({ proposal_status: Post.Proposal_Status.VOTING }, { patch: true }) + return setProposalOptions({ userId: user.id, postId: post.id, options: [option1, option2] }) + .then(() => expect.fail('should reject')) + .catch(e => expect(e).to.match(/Proposal options cannot be changed unless the proposal is in 'discussion'/)) + }) + + it('does not allow adding a vote if the proposal_status is not "voting"', async () => { + await post.save({ proposal_status: Post.Proposal_Status.COMPLETED }, { patch: true }) + return addProposalVote({ userId: user.id, postId: post.id, optionId }) + .then(() => expect.fail('should reject')) + .catch(e => expect(e).to.match(/Cannot vote on a proposal that is in discussion or completed/)) + }) + + it('does not allow removing a vote if the proposal_status is not "voting"', async () => { + await post.save({ proposal_status: Post.Proposal_Status.COMPLETED }, { patch: true }) + return removeProposalVote({ userId: user.id, postId: post.id, optionId }) + .then(() => expect.fail('should reject')) + .catch(e => expect(e).to.match(/Cannot vote on a proposal that is in discussion or completed/)) + }) + + it('does not allow swapping a vote if the proposal_status is not "voting"', async () => { + await post.save({ proposal_status: Post.Proposal_Status.COMPLETED }, { patch: true }) + return swapProposalVote({ userId: user.id, postId: post.id, removeOptionId: optionId, addOptionId: optionId2 }) + .then(() => expect.fail('should reject')) + .catch(e => expect(e).to.match(/Cannot vote on a proposal that is in discussion or completed/)) + }) +}) diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index 42166bd08..cf7877744 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -1587,9 +1587,6 @@ type Person { # The set of Skills this person want to learn skillsToLearn(first: Int, cursor: ID): SkillQuerySet - - # Upvotes on posts that this person has made. DEPRECATED, use reactions instead - votes(first: Int, offset: Int, order: String): VoteQuerySet } # Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set @@ -1621,6 +1618,8 @@ type Post { id: ID # NOT USED RIGHT NOW. Whether this post can accept financial contributions acceptContributions: Boolean + # whether voting is anonymous for this proposal (Only used by proposal type posts) + anonymity: Boolean # Was this post marked an announcement by a group moderator? announcement: Boolean # Number of attachments on this post @@ -1654,16 +1653,20 @@ type Post { myEventResponse: String # The reactions of the logged-in user to this myReactions: [Reaction] - # Whether the logged-in user has upvoted the post or not - myVote: Boolean # Number of people that have one or more reactions to a post peopleReactedTotal: Int # Number of total reactions on the post postReactionsTotal: Int # Number of total "members" of the post. Used for Projects only right now postMembershipsTotal: Int + # A proposal's outcome: 'cancelled', 'quorum-not-met', 'in-progress', 'successful', 'tie' + proposalOutcome: String + # The status of a proposal post: 'discussion', 'voting', 'completed' + proposalStatus: String # Link to a project management tool or service. Only used for Projects right now projectManagementLink: String + # The percentage of membership that need to vote for a proposal to complete successfully + quorum: Int # Number of total reactions on the post reactionsTotal: Int # Summary of reactions on the post @@ -1681,8 +1684,6 @@ type Post { # Post type: 'discussion', 'event', 'offer', 'project', 'request', or 'resource' type: String updatedAt: Date - # Number of upvotes on the post. DEPRECATED - votesTotal: Int # The attachments (images, videos or files) added to the post attachments(type: String): [Attachment] @@ -1717,6 +1718,12 @@ type Post { # reactions to this post postReactions: [Reaction] + # For proposal posts, load all the different options that people can vote on. + proposalOptions: ProposalOptionQuerySet + + # Load all votes for a proposal post. Note: not a queryset; to avoid paginating votes + proposalVotes: ProposalVoteQuerySet + # The topics that have been added to this post topics: [Topic] } @@ -1750,6 +1757,27 @@ type Point { lng: String } +type ProposalOption { + id: ID + postId: ID + # Required: The text of the proposal option + text: String + # Optional longer description of the option + description: String +} + +type ProposalVote { + id: ID + # The person who voted + userId: ID + # The post that the vote is for + postId: ID + # The option that the person voted for + optionId: ID + # timestamp of when the vote was created + createdAt: Date +} + # A question asked when someone is trying to join a group type Question { id: Int @@ -1919,21 +1947,18 @@ type UserSettings { streamPostType: String } -# An upvote on a post. DEPRECATED -type Vote { - id: ID - createdAt: Date - # The Post being voted on - post: Post - # The Person doing the voting - voter: Person +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type ProposalOptionQuerySet { + total: Int + hasMore: Boolean + items: [ProposalOption] } # Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set -type VoteQuerySet { +type ProposalVoteQuerySet { total: Int hasMore: Boolean - items: [Vote] + items: [ProposalVote] } # A widget is a block of styled content that appears in an Explore page for a group diff --git a/api/models/Post.js b/api/models/Post.js index aedee4c96..487e87c3f 100644 --- a/api/models/Post.js +++ b/api/models/Post.js @@ -77,14 +77,18 @@ module.exports = bookshelf.Model.extend(Object.assign({ return this.get('type') === Post.Type.CHAT }, - isWelcome: function () { - return this.get('type') === Post.Type.WELCOME + isProposal: function () { + return this.get('type') === Post.Type.Proposal }, isThread: function () { return this.get('type') === Post.Type.THREAD }, + isWelcome: function () { + return this.get('type') === Post.Type.WELCOME + }, + commentsTotal: function () { return this.get('num_comments') }, @@ -93,7 +97,7 @@ module.exports = bookshelf.Model.extend(Object.assign({ return this.get('num_people_reacts') }, - votesTotal: function () { + votesTotal: function () { // TODO PROPOSAL: check if this can be removed return this.get('num_people_reacts') }, @@ -163,6 +167,14 @@ module.exports = bookshelf.Model.extend(Object.assign({ return this.hasMany(ProjectContribution) }, + proposalOptions: function () { + return this.hasMany(ProposalOption) + }, + + proposalVotes: function () { + return this.hasMany(ProposalVote) + }, + reactions: function () { return this.hasMany(Reaction, 'entity_id').where({ 'reactions.entity_type': 'post' }) }, @@ -201,11 +213,11 @@ module.exports = bookshelf.Model.extend(Object.assign({ return q }, - + // TOOD PROPOSAL: check if this can be removed userVote: function (userId) { return this.votes().query({ where: { user_id: userId, entity_type: 'post' } }).fetchOne() }, - + // TOOD PROPOSAL: check if this can be removed votes: function () { return this.hasMany(Reaction, 'entity_id').where('reactions.entity_type', 'post') }, @@ -250,7 +262,7 @@ module.exports = bookshelf.Model.extend(Object.assign({ const creator = refineOne(user, [ 'id', 'name', 'avatar_url' ]) const topics = refineMany(tags, [ 'id', 'name' ]) - + // TODO PROPOSAL: check if proposal addtions need to be added here? // TODO: Sanitization -- sanitize details here if not passing through `text` getter return Object.assign({}, refineOne( @@ -321,12 +333,40 @@ module.exports = bookshelf.Model.extend(Object.assign({ return updatedFollowers.concat(newFollowers) }, + async addProposalVote ({ userId, optionId }) { + return ProposalVote.forge({ post_id: this.id, user_id: userId, option_id: optionId, created_at: new Date() }).save() + }, + async removeFollowers (usersOrIds, { transacting } = {}) { return this.updateFollowers(usersOrIds, { active: false }, { transacting }) }, + async removeProposalVote ({ userId, optionId }) { + const vote = await ProposalVote.query({ where: { user_id: userId, option_id: optionId } }).fetch() + return vote.destroy() + }, + + async setProposalOptions (options = []) { + return bookshelf.knex.raw(`BEGIN; + + DELETE FROM proposal_options + WHERE post_id = ${this.id}; + + INSERT INTO proposal_options (post_id, text, description) + VALUES + ${options.map(option => `(${this.id}, '${option.text}', '${option.description}')`).join(', ')} + RETURNING id; + + COMMIT;`) + }, + + async swapProposalVote ({ userId, removeOptionId, addOptionId }) { + await this.removeProposalVote({ userId, optionId: removeOptionId }) + return this.addProposalVote({ userId, optionId: addOptionId }) + }, + async updateFollowers (usersOrIds, attrs, { transacting } = {}) { - if (usersOrIds.length == 0) return [] + if (usersOrIds.length === 0) return [] const userIds = usersOrIds.map(x => x instanceof User ? x.id : x) const existingFollowers = await this.postUsers() .query(q => q.whereIn('user_id', userIds)).fetch({ transacting }) @@ -512,10 +552,25 @@ module.exports = bookshelf.Model.extend(Object.assign({ EVENT: 'event', OFFER: 'offer', PROJECT: 'project', + PROPOSAL: 'proposal', REQUEST: 'request', RESOURCE: 'resource', THREAD: 'thread', - WELCOME: 'welcome', + WELCOME: 'welcome' + }, + + Proposal_Status: { + DISCUSSION: 'discussion', + COMPLETED: 'completed', + VOTING: 'voting' + }, + + Proposal_Outcome: { + CANCELLED: 'cancelled', + QUORUM_NOT_MET: 'quorum-not-met', + IN_PROGRESS: 'in-progress', + SUCCESSFUL: 'successful', + TIE: 'tie' }, // TODO Consider using Visibility property for more granular privacy @@ -558,14 +613,13 @@ module.exports = bookshelf.Model.extend(Object.assign({ isVisibleToUser: async function (postId, userId) { if (!postId || !userId) return Promise.resolve(false) - const post = await Post.find(postId) - if (post.isPublic()) return true const postGroupIds = await PostMembership.query() .where({ post_id: postId }).pluck('group_id') const userGroupIds = await Group.pluckIdsForMember(userId) + if (intersection(postGroupIds, userGroupIds).length > 0) return true if (await post.isFollowed(userId)) return true diff --git a/api/models/ProposalOption.js b/api/models/ProposalOption.js new file mode 100644 index 000000000..384bee7ae --- /dev/null +++ b/api/models/ProposalOption.js @@ -0,0 +1,10 @@ +module.exports = bookshelf.Model.extend({ + tableName: 'proposal_options', + requireFetch: false, + post: function () { + return this.belongsTo(Post, 'post_id') + } +}, { + + +}) diff --git a/api/models/ProposalVote.js b/api/models/ProposalVote.js new file mode 100644 index 000000000..9eeb92587 --- /dev/null +++ b/api/models/ProposalVote.js @@ -0,0 +1,17 @@ +module.exports = bookshelf.Model.extend({ + tableName: 'proposal_votes', + requireFetch: false, + + option: function () { + return this.belongsTo(ProposalOption, 'option_id') + }, + post: function () { + return this.belongsTo(Post, 'post_id') + }, + user: function () { + return this.belongsTo(User, 'user_id') + } +}, { + + +}) diff --git a/migrations/20240218134105_proposals.js b/migrations/20240218134105_proposals.js new file mode 100644 index 000000000..88f67c21c --- /dev/null +++ b/migrations/20240218134105_proposals.js @@ -0,0 +1,47 @@ +exports.up = async function (knex, Promise) { + await knex.schema.table('posts', table => { + table.bigInteger('quorum') + table.text('proposal_status').index() + table.text('proposal_outcome').index() + table.text('proposal_type') + /* + added proposal_ prefix to status and outcome because they are very generic column names + and I don't want to confuse future developers as to why they are largely null + */ + table.text('anonymity') + }) + + await knex.schema.createTable('proposal_options', table => { + table.increments().primary() + table.bigInteger('post_id').references('id').inTable('posts').notNullable() + table.text('text').notNullable() + table.text('description') + }) + + await knex.schema.createTable('proposal_votes', table => { + table.increments().primary() + table.bigInteger('post_id').references('id').inTable('posts').notNullable() + table.bigInteger('option_id').references('id').inTable('proposal_options').notNullable() + table.bigInteger('user_id').references('id').inTable('users').notNullable() + table.timestamp('created_at') + }) + + await knex.raw('alter table proposal_options alter constraint proposal_options_post_id_foreign deferrable initially deferred') + await knex.raw('alter table proposal_votes alter constraint proposal_votes_post_id_foreign deferrable initially deferred') + await knex.raw('alter table proposal_votes alter constraint proposal_votes_option_id_foreign deferrable initially deferred') + await knex.raw('alter table proposal_votes alter constraint proposal_votes_user_id_foreign deferrable initially deferred') +} + +exports.down = async function (knex, Promise) { + await knex.schema.dropTable('proposal_votes') + await knex.schema.dropTable('proposal_options') + + await knex.schema.table('posts', table => { + table.dropIndex(['proposal_status']) + table.dropIndex(['proposal_outcome']) + table.dropColumn('quorum') + table.dropColumn('proposal_status') + table.dropColumn('proposal_outcome') + table.dropColumn('anonymity') + }) +} diff --git a/migrations/schema.sql b/migrations/schema.sql index eb8571e2d..36ede2541 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -1812,7 +1812,12 @@ CREATE TABLE public.posts ( project_management_link character varying(255), link_preview_featured boolean DEFAULT false, reactions_summary jsonb, - timezone character varying(255) + timezone character varying(255), + quorum bigint, + proposal_status text, + proposal_outcome text, + proposal_type text, + anonymity text ); @@ -1957,6 +1962,69 @@ CREATE SEQUENCE public.project_roles_id_seq ALTER SEQUENCE public.project_roles_id_seq OWNED BY public.project_roles.id; +-- +-- Name: proposal_options; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.proposal_options ( + id integer NOT NULL, + post_id bigint NOT NULL, + text text NOT NULL, + description text +); + + +-- +-- Name: proposal_options_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.proposal_options_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: proposal_options_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.proposal_options_id_seq OWNED BY public.proposal_options.id; + + +-- +-- Name: proposal_votes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.proposal_votes ( + id integer NOT NULL, + post_id bigint NOT NULL, + option_id bigint NOT NULL, + user_id bigint NOT NULL, + created_at timestamp with time zone +); + + +-- +-- Name: proposal_votes_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.proposal_votes_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: proposal_votes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.proposal_votes_id_seq OWNED BY public.proposal_votes.id; -- -- Name: users_seq; Type: SEQUENCE; Schema: public; Owner: - @@ -3005,6 +3073,18 @@ ALTER TABLE ONLY public.project_contributions ALTER COLUMN id SET DEFAULT nextva ALTER TABLE ONLY public.project_roles ALTER COLUMN id SET DEFAULT nextval('public.project_roles_id_seq'::regclass); +-- +-- Name: proposal_options id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.proposal_options ALTER COLUMN id SET DEFAULT nextval('public.proposal_options_id_seq'::regclass); + + +-- +-- Name: proposal_votes id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.proposal_votes ALTER COLUMN id SET DEFAULT nextval('public.proposal_votes_id_seq'::regclass); -- -- Name: push_notifications id; Type: DEFAULT; Schema: public; Owner: - @@ -3717,6 +3797,19 @@ ALTER TABLE ONLY public.project_contributions ALTER TABLE ONLY public.project_roles ADD CONSTRAINT project_roles_pkey PRIMARY KEY (id); +-- +-- Name: proposal_options proposal_options_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.proposal_options + ADD CONSTRAINT proposal_options_pkey PRIMARY KEY (id); + +-- +-- Name: proposal_votes proposal_votes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.proposal_votes + ADD CONSTRAINT proposal_votes_pkey PRIMARY KEY (id); -- -- Name: questions questions_pkey; Type: CONSTRAINT; Schema: public; Owner: - @@ -5367,6 +5460,38 @@ ALTER TABLE ONLY public.project_contributions ALTER TABLE ONLY public.project_roles ADD CONSTRAINT project_roles_post_id_foreign FOREIGN KEY (post_id) REFERENCES public.posts(id) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: proposal_options proposal_options_post_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.proposal_options + ADD CONSTRAINT proposal_options_post_id_foreign FOREIGN KEY (post_id) REFERENCES public.posts(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: proposal_votes proposal_votes_option_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.proposal_votes + ADD CONSTRAINT proposal_votes_option_id_foreign FOREIGN KEY (option_id) REFERENCES public.proposal_options(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: proposal_votes proposal_votes_post_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.proposal_votes + ADD CONSTRAINT proposal_votes_post_id_foreign FOREIGN KEY (post_id) REFERENCES public.posts(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: proposal_votes proposal_votes_user_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.proposal_votes + ADD CONSTRAINT proposal_votes_user_id_foreign FOREIGN KEY (user_id) REFERENCES public.users(id) DEFERRABLE INITIALLY DEFERRED; + + -- -- Name: push_notifications push_notifications_device_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - diff --git a/package.json b/package.json index 1b56b4a02..bc062d078 100644 --- a/package.json +++ b/package.json @@ -229,6 +229,8 @@ "PostUser", "ProjectRole", "ProjectContributions", + "ProposalOption", + "ProposalVote", "PushNotification", "Question", "Queue", diff --git a/test/setup/factories.js b/test/setup/factories.js index 79d7a650a..632794c7a 100644 --- a/test/setup/factories.js +++ b/test/setup/factories.js @@ -112,6 +112,17 @@ module.exports = { return new GroupExtension(attrs) }, + proposalOption: attrs => { + return new ProposalOption(merge({ + text: faker.random.words(3), + description: faker.lorem.sentences(2) + }, attrs)) + }, + + proposalVote: attrs => { + return new ProposalVote(attrs) + }, + extension: attrs => { return new Extension(attrs) }, From 8bb6a4420a3f311e248a6ff149e03185d9b49ab7 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 27 Feb 2024 12:47:55 -0800 Subject: [PATCH 02/18] Add mutations to schema.graphql --- api/graphql/index.js | 10 +++++----- api/graphql/schema.graphql | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/api/graphql/index.js b/api/graphql/index.js index e27886ab4..c137a8095 100644 --- a/api/graphql/index.js +++ b/api/graphql/index.js @@ -228,8 +228,8 @@ export function makeAuthenticatedQueries (userId, fetchOne, fetchMany) { return Group.where(bookshelf.knex.raw('slug = ?', slug)) .count() .then(count => { - if (count > 0) return {exists: true} - return {exists: false} + if (count > 0) return { exists: true } + return { exists: false } }) } throw new GraphQLYogaError('Slug is invalid') @@ -260,7 +260,7 @@ export function makeAuthenticatedQueries (userId, fetchOne, fetchMany) { // FIXME this shouldn't be used directly here -- there should be some // way of integrating this into makeModels and using the presentation // logic that's already in the fetcher - return presentQuerySet(models, merge(args, {total})) + return presentQuerySet(models, merge(args, { total })) }) }, skills: (root, args) => fetchMany('Skill', args), @@ -294,7 +294,7 @@ export function makeMutations (expressContext, userId, isAdmin, fetchOne) { acceptJoinRequest: (root, { joinRequestId }) => acceptJoinRequest(userId, joinRequestId), - addGroupRole: (root, { groupId, color, name, description, emoji }) => addGroupRole({userId, groupId, color, name, description, emoji}), + addGroupRole: (root, { groupId, color, name, description, emoji }) => addGroupRole({ userId, groupId, color, name, description, emoji }), addModerator: (root, { personId, groupId }) => addModerator(userId, personId, groupId), @@ -499,7 +499,7 @@ export function makeMutations (expressContext, userId, isAdmin, fetchOne) { updateWidget: (root, { id, changes }) => updateWidget(id, changes), useInvitation: (root, { invitationToken, accessCode }) => - useInvitation(userId, invitationToken, accessCode), + useInvitation(userId, invitationToken, accessCode) } } diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index cf7877744..41be01877 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -2010,6 +2010,8 @@ type Mutation { addPeopleToProjectRole(peopleIds: [ID], projectRoleId: ID): GenericResult # Add a Post to a Collection addPostToCollection(collectionId: ID, postId: ID): GenericResult + # Add a rvote to a proposal + addProposalVote(postId: ID, optionId: ID): GenericResult # For a moderator to add a badge/role to a group to a member of a group addRoleToMember( groupRoleId: ID, @@ -2157,6 +2159,8 @@ type Mutation { removePost(postId: ID, slug: String, groupId: ID): GenericResult # Remove a Post from a Collection, if you have permission to removePostFromCollection(collectionId: ID, postId: ID): GenericResult + # Remove a vote from a proposal + removeProposalVote(postId: ID, optionId: ID): GenericResult # Remove a badge/role from a member of a group, inititated by that member or by a group moderator removeRoleFromMember(groupRoleId: ID, groupId: ID, personId: ID): GenericResult # Remove a skill from your user profile @@ -2192,8 +2196,12 @@ type Mutation { sendEmailVerification(email: String!): GenericResult, # Send a password reset email sendPasswordReset(email: String!): GenericResult, + # set options for a proposal + setProposalOptions(postId: ID, options: [ProposalOptionInput]): [ProposalOption] # Subscribe or unsubscribe to a Topic in a Group subscribe(groupId: ID, topicId: ID, isSubscribing: Boolean): GenericResult + # Swap votes on a proposal + swapProposalVote(postId: ID, addOptionId: ID, removeOptionId: ID): GenericResult # Unlink profile from previously linked social platform. Provider can be 'facebook', 'twitter', or 'linkedin' unlinkAccount(provider: String): GenericResult # Unblock a user @@ -2731,6 +2739,14 @@ input PostInput { type: String } +input ProposalOptionInput { + id: ID + # Required: Title of the option + text: String + # Optional: more detailed text for the option + description: String +} + # A Question to be asked before joining a Group input QuestionInput { id: Int From 0ff43a5665b92eca78293ceb7b383205a7069d49 Mon Sep 17 00:00:00 2001 From: Tibet Sprague Date: Wed, 28 Feb 2024 13:06:52 -0800 Subject: [PATCH 03/18] Tweak to package.json author and license --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 2dd250e8d..0beb24397 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,8 @@ "name": "hylo-node", "description": "Hylo Server", "keywords": [], - "author": "Tibet Sprague ", - "license": "GNU AFFERO GENERAL PUBLIC LICENSE v3", + "author": "Hylo ", + "license": "Apache-2.0", "private": true, "version": "5.7.1", "repository": { From a84d09700d9b255a1c43256bf15d2763e8be5f0c Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 28 Feb 2024 13:27:20 -0800 Subject: [PATCH 04/18] review tweaks --- api/models/Post.js | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/api/models/Post.js b/api/models/Post.js index 487e87c3f..b484c9033 100644 --- a/api/models/Post.js +++ b/api/models/Post.js @@ -97,10 +97,6 @@ module.exports = bookshelf.Model.extend(Object.assign({ return this.get('num_people_reacts') }, - votesTotal: function () { // TODO PROPOSAL: check if this can be removed - return this.get('num_people_reacts') - }, - // Relations activities: function () { @@ -213,14 +209,6 @@ module.exports = bookshelf.Model.extend(Object.assign({ return q }, - // TOOD PROPOSAL: check if this can be removed - userVote: function (userId) { - return this.votes().query({ where: { user_id: userId, entity_type: 'post' } }).fetchOne() - }, - // TOOD PROPOSAL: check if this can be removed - votes: function () { - return this.hasMany(Reaction, 'entity_id').where('reactions.entity_type', 'post') - }, // TODO: this is confusing and we are not using, remove for now? children: function () { @@ -258,27 +246,29 @@ module.exports = bookshelf.Model.extend(Object.assign({ // TODO: if we were in a position to avoid duplicating the graphql layer // here, that'd be grand. getNewPostSocketPayload: function () { - const { groups, linkPreview, tags, user } = this.relations + const { groups, linkPreview, tags, user, proposalOptions } = this.relations const creator = refineOne(user, [ 'id', 'name', 'avatar_url' ]) const topics = refineMany(tags, [ 'id', 'name' ]) - // TODO PROPOSAL: check if proposal addtions need to be added here? + // TODO: Sanitization -- sanitize details here if not passing through `text` getter return Object.assign({}, refineOne( this, - ['created_at', 'description', 'id', 'name', 'num_people_reacts', 'timezone', 'type', 'updated_at', 'num_votes'], + ['created_at', 'description', 'id', 'name', 'num_people_reacts', 'timezone', 'type', 'updated_at', 'num_votes', 'proposalType', 'proposalStatus', 'proposalOutcome'], { description: 'details', name: 'title', num_people_reacts: 'peopleReactedTotal', num_votes: 'votesTotal' } ), { // Shouldn't have commenters immediately after creation commenters: [], + proposalVotes: [], commentsTotal: 0, details: this.details(), groups: refineMany(groups, [ 'id', 'name', 'slug' ]), creator, linkPreview: refineOne(linkPreview, [ 'id', 'image_url', 'title', 'description', 'url' ]), topics, + proposalOptions, // TODO: Once legacy site is decommissioned, these are no longer required. creatorId: creator.id, From c5321703c9be5dc37877559a5fcb93a20d1ecdb2 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 26 Mar 2024 14:04:03 -0700 Subject: [PATCH 05/18] Post review tweaks --- api/graphql/makeModels.js | 2 +- api/graphql/mutations/post.js | 8 ++++---- api/graphql/mutations/post.test.js | 8 ++++---- api/graphql/schema.graphql | 14 +++++++++----- api/models/Post.js | 11 +++++++++-- api/models/ProposalOption.js | 1 - migrations/20240218134105_proposals.js | 10 +++++++--- migrations/schema.sql | 6 ++++-- 8 files changed, 38 insertions(+), 22 deletions(-) diff --git a/api/graphql/makeModels.js b/api/graphql/makeModels.js index a19931d73..494528418 100644 --- a/api/graphql/makeModels.js +++ b/api/graphql/makeModels.js @@ -188,7 +188,7 @@ export default function makeModels (userId, isAdmin, apiClient) { attributes: [ 'accept_contributions', 'announcement', - 'anonymity', + 'anonymous_voting', 'commentsTotal', 'created_at', 'donations_link', diff --git a/api/graphql/mutations/post.js b/api/graphql/mutations/post.js index 2fcd93a7b..d4c0d8040 100644 --- a/api/graphql/mutations/post.js +++ b/api/graphql/mutations/post.js @@ -60,7 +60,7 @@ export async function addProposalVote ({ userId, postId, optionId }) { return Post.find(postId) .then(post => { - if (post.get('proposal_status') !== Post.Proposal_Status.VOTING) throw new GraphQLYogaError('Cannot vote on a proposal that is in discussion or completed') + if (post.get('proposal_status') !== Post.Proposal_Status.VOTING) throw new GraphQLYogaError('Cannot vote on a proposal that is in discussion or completed') // TODO PROPOSALS: edit this for casual proposals return post.addProposalVote({ userId, optionId }) }) .catch((err) => { throw new GraphQLYogaError(`adding of vote failed: ${err}`) }) @@ -75,7 +75,7 @@ export async function removeProposalVote ({ userId, postId, optionId }) { return Post.find(postId) .then(post => { - if (post.get('proposal_status') !== Post.Proposal_Status.VOTING) throw new GraphQLYogaError('Cannot vote on a proposal that is in discussion or completed') + if (post.get('proposal_status') !== Post.Proposal_Status.VOTING) throw new GraphQLYogaError('Cannot vote on a proposal that is in discussion or completed') // TODO PROPOSALS: edit this for casual proposals return post.removeProposalVote({ userId, optionId }) }) .catch((err) => { throw new GraphQLYogaError(`removal of vote failed: ${err}`) }) @@ -88,7 +88,7 @@ export async function setProposalOptions ({ userId, postId, options }) { if (!authorized) throw new GraphQLYogaError("You don't have permission to modify this post") return Post.find(postId) .then(post => { - if (post.get('proposal_status') !== Post.Proposal_Status.DISCUSSION) throw new GraphQLYogaError("Proposal options cannot be changed unless the proposal is in 'discussion'") + if (post.get('proposal_status') !== Post.Proposal_Status.DISCUSSION) throw new GraphQLYogaError("Proposal options cannot be changed unless the proposal is in 'discussion'") // TODO PROPOSALS: edit this for casual proposals return post.setProposalOptions(options) }) .catch((err) => { throw new GraphQLYogaError(`setting of options failed: ${err}`) }) @@ -103,7 +103,7 @@ export async function swapProposalVote ({ userId, postId, removeOptionId, addOpt const post = await Post.find(postId) if (!post) throw new GraphQLYogaError(`Couldn't find post for ${postId}`) - if (post.get('proposal_status') !== Post.Proposal_Status.VOTING) throw new GraphQLYogaError('Cannot vote on a proposal that is in discussion or completed') + if (post.get('proposal_status') !== Post.Proposal_Status.VOTING) throw new GraphQLYogaError('Cannot vote on a proposal that is in discussion or completed') // TODO PROPOSALS: edit this for casual proposals try { await post.removeProposalVote({ userId, optionId: removeOptionId }) diff --git a/api/graphql/mutations/post.test.js b/api/graphql/mutations/post.test.js index 790ce9bce..b53ddffed 100644 --- a/api/graphql/mutations/post.test.js +++ b/api/graphql/mutations/post.test.js @@ -55,10 +55,10 @@ describe('ProposalVote', () => { .then(() => user.joinGroup(g1)) .then(() => post.groups().attach(g1.id)) .then(async () => { - option1 = { post_id: post.id, text: 'option1', description: 'description1' } - option2 = { post_id: post.id, text: 'option2', description: 'description2' } - option3 = { post_id: post.id, text: 'third', description: 'description third' } - option4 = { post_id: post.id, text: 'fourth', description: 'description fourth' } + option1 = { post_id: post.id, text: 'option1' } + option2 = { post_id: post.id, text: 'option2' } + option3 = { post_id: post.id, text: 'third' } + option4 = { post_id: post.id, text: 'fourth' } await post.save({ proposal_status: Post.Proposal_Status.DISCUSSION }, { patch: true }) return post.setProposalOptions([option1, option2]) diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index 41be01877..578ae315c 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -1619,7 +1619,7 @@ type Post { # NOT USED RIGHT NOW. Whether this post can accept financial contributions acceptContributions: Boolean # whether voting is anonymous for this proposal (Only used by proposal type posts) - anonymity: Boolean + anonymousVoting: Boolean # Was this post marked an announcement by a group moderator? announcement: Boolean # Number of attachments on this post @@ -1762,8 +1762,10 @@ type ProposalOption { postId: ID # Required: The text of the proposal option text: String - # Optional longer description of the option - description: String + # Optional: The emoji for the proposal option + emoji: String + # Optional: The color for the proposal option + color: String } type ProposalVote { @@ -2743,8 +2745,10 @@ input ProposalOptionInput { id: ID # Required: Title of the option text: String - # Optional: more detailed text for the option - description: String + # Optional: emoji to represent the option + emoji: String + # Optional: hexcode color to represent the option + color: String } # A Question to be asked before joining a Group diff --git a/api/models/Post.js b/api/models/Post.js index b484c9033..83aa37093 100644 --- a/api/models/Post.js +++ b/api/models/Post.js @@ -342,9 +342,9 @@ module.exports = bookshelf.Model.extend(Object.assign({ DELETE FROM proposal_options WHERE post_id = ${this.id}; - INSERT INTO proposal_options (post_id, text, description) + INSERT INTO proposal_options (post_id, text, color, emoji) VALUES - ${options.map(option => `(${this.id}, '${option.text}', '${option.description}')`).join(', ')} + ${options.map(option => `(${this.id}, '${option.text}', '${option.color}', '${option.emoji}')`).join(', ')} RETURNING id; COMMIT;`) @@ -563,6 +563,13 @@ module.exports = bookshelf.Model.extend(Object.assign({ TIE: 'tie' }, + Proposal_Type: { + SINGLE: 'single', + MULTI_UNRESTRICTED: 'multi-unrestricted', + CONSENSUS: 'consensus', // Stricter form of single vote, all votes must be for the same option to 'pass' + MAJORITY: 'majority' // one option must have more than 50% of votes for the proposal to 'pass' + }, + // TODO Consider using Visibility property for more granular privacy // as our work on Public Posts evolves Visibility: { diff --git a/api/models/ProposalOption.js b/api/models/ProposalOption.js index 384bee7ae..01b19c704 100644 --- a/api/models/ProposalOption.js +++ b/api/models/ProposalOption.js @@ -6,5 +6,4 @@ module.exports = bookshelf.Model.extend({ } }, { - }) diff --git a/migrations/20240218134105_proposals.js b/migrations/20240218134105_proposals.js index 88f67c21c..9700d94e8 100644 --- a/migrations/20240218134105_proposals.js +++ b/migrations/20240218134105_proposals.js @@ -4,18 +4,21 @@ exports.up = async function (knex, Promise) { table.text('proposal_status').index() table.text('proposal_outcome').index() table.text('proposal_type') + table.integer('proposal_vote_limit') + table.boolean('proposal_strict').defaultTo(false) /* added proposal_ prefix to status and outcome because they are very generic column names and I don't want to confuse future developers as to why they are largely null */ - table.text('anonymity') + table.text('anonymous_voting') }) await knex.schema.createTable('proposal_options', table => { table.increments().primary() table.bigInteger('post_id').references('id').inTable('posts').notNullable() + table.text('emoji') + table.text('color') table.text('text').notNullable() - table.text('description') }) await knex.schema.createTable('proposal_votes', table => { @@ -42,6 +45,7 @@ exports.down = async function (knex, Promise) { table.dropColumn('quorum') table.dropColumn('proposal_status') table.dropColumn('proposal_outcome') - table.dropColumn('anonymity') + table.dropColumn('anonymous_voting') + table.dropColumn('proposal_vote_limit') }) } diff --git a/migrations/schema.sql b/migrations/schema.sql index 36ede2541..433ba3754 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -1817,7 +1817,8 @@ CREATE TABLE public.posts ( proposal_status text, proposal_outcome text, proposal_type text, - anonymity text + anonymous_voting text, + proposal_vote_limit integer ); @@ -1970,7 +1971,8 @@ CREATE TABLE public.proposal_options ( id integer NOT NULL, post_id bigint NOT NULL, text text NOT NULL, - description text + emoji text, + color text ); From 6b791b6427f720b0cfda6382075bf25aa83b6409 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 26 Mar 2024 14:40:56 -0700 Subject: [PATCH 06/18] Make sure proposal_type column is removed in migration rollback --- migrations/20240218134105_proposals.js | 1 + 1 file changed, 1 insertion(+) diff --git a/migrations/20240218134105_proposals.js b/migrations/20240218134105_proposals.js index 9700d94e8..f9ea3194c 100644 --- a/migrations/20240218134105_proposals.js +++ b/migrations/20240218134105_proposals.js @@ -44,6 +44,7 @@ exports.down = async function (knex, Promise) { table.dropIndex(['proposal_outcome']) table.dropColumn('quorum') table.dropColumn('proposal_status') + table.dropColumn('proposal_type') table.dropColumn('proposal_outcome') table.dropColumn('anonymous_voting') table.dropColumn('proposal_vote_limit') From 1836f18ae7f594b8bdfe9d6183a12b0815f455e7 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 16 Apr 2024 13:11:45 -0700 Subject: [PATCH 07/18] EVO adjustments --- api/graphql/index.js | 4 + api/graphql/makeModels.js | 22 ++++ api/graphql/mutations/index.js | 1 + api/graphql/mutations/post.js | 25 +++-- api/graphql/mutations/post.test.js | 21 +++- api/graphql/schema.graphql | 48 ++++++--- api/models/Post.js | 142 ++++++++++++++++++------- api/models/post/createPost.js | 8 +- api/models/post/setupPostAttrs.js | 24 ++++- api/models/post/updatePost.js | 40 +++---- api/models/post/validatePostData.js | 6 +- api/services/Search/util.js | 6 +- api/services/Search/util.test.js | 2 +- cron.js | 5 +- lib/group/digest2/formatData.js | 7 +- migrations/20240218134105_proposals.js | 3 +- migrations/schema.sql | 1 + test/unit/services/Search.test.js | 4 +- test/unit/services/digest2.test.js | 4 + 19 files changed, 270 insertions(+), 103 deletions(-) diff --git a/api/graphql/index.js b/api/graphql/index.js index c137a8095..585936988 100644 --- a/api/graphql/index.js +++ b/api/graphql/index.js @@ -102,6 +102,7 @@ import { updateMe, updateMembership, updatePost, + updateProposalOptions, updateStripeAccount, updateWidget, useInvitation, @@ -492,6 +493,9 @@ export function makeMutations (expressContext, userId, isAdmin, fetchOne) { updateMembership: (root, args) => updateMembership(userId, args), updatePost: (root, args) => updatePost(userId, args), + + updateProposalOptions: (root, { postId, options }) => updateProposalOptions({ userId, postId, options }), + updateComment: (root, args) => updateComment(userId, args), updateStripeAccount: (root, { accountId }) => updateStripeAccount(userId, accountId), diff --git a/api/graphql/makeModels.js b/api/graphql/makeModels.js index 494528418..cb3f06a38 100644 --- a/api/graphql/makeModels.js +++ b/api/graphql/makeModels.js @@ -199,6 +199,7 @@ export default function makeModels (userId, isAdmin, apiClient) { 'location', 'project_management_link', 'proposal_status', + 'proposal_type', 'proposal_outcome', 'quorum', 'reactions_summary', @@ -977,6 +978,27 @@ export default function makeModels (userId, isAdmin, apiClient) { ] }, + ProposalOption: { + model: ProposalOption, + attributes: [ + 'emoji', + 'color', + 'text' + ] + }, + + ProposalVote: { + model: ProposalVote, + attributes: [ + 'created_at', + 'id', + 'option_id' + ], + relations: [ + 'user' + ] + }, + GroupExtension: { model: GroupExtension, attributes: [ diff --git a/api/graphql/mutations/index.js b/api/graphql/mutations/index.js index 26b4f9dd0..e41a25b1f 100644 --- a/api/graphql/mutations/index.js +++ b/api/graphql/mutations/index.js @@ -68,6 +68,7 @@ export { removeProposalVote, swapProposalVote, updatePost, + updateProposalOptions, deletePost, pinPost } from './post' diff --git a/api/graphql/mutations/post.js b/api/graphql/mutations/post.js index d4c0d8040..52c231a6d 100644 --- a/api/graphql/mutations/post.js +++ b/api/graphql/mutations/post.js @@ -60,7 +60,7 @@ export async function addProposalVote ({ userId, postId, optionId }) { return Post.find(postId) .then(post => { - if (post.get('proposal_status') !== Post.Proposal_Status.VOTING) throw new GraphQLYogaError('Cannot vote on a proposal that is in discussion or completed') // TODO PROPOSALS: edit this for casual proposals + if (post.get('proposal_status') !== Post.Proposal_Status.VOTING && post.get('proposal_status') !== Post.Proposal_Status.CASUAL) throw new GraphQLYogaError('Cannot vote on a proposal that is in discussion or completed') return post.addProposalVote({ userId, optionId }) }) .catch((err) => { throw new GraphQLYogaError(`adding of vote failed: ${err}`) }) @@ -72,10 +72,9 @@ export async function removeProposalVote ({ userId, postId, optionId }) { const authorized = await Post.isVisibleToUser(postId, userId) if (!authorized) throw new GraphQLYogaError("You don't have permission to vote on this post") - return Post.find(postId) .then(post => { - if (post.get('proposal_status') !== Post.Proposal_Status.VOTING) throw new GraphQLYogaError('Cannot vote on a proposal that is in discussion or completed') // TODO PROPOSALS: edit this for casual proposals + if (post.get('proposal_status') !== Post.Proposal_Status.VOTING && post.get('proposal_status') !== Post.Proposal_Status.CASUAL) throw new GraphQLYogaError('Cannot vote on a proposal that is in discussion or completed') return post.removeProposalVote({ userId, optionId }) }) .catch((err) => { throw new GraphQLYogaError(`removal of vote failed: ${err}`) }) @@ -88,8 +87,22 @@ export async function setProposalOptions ({ userId, postId, options }) { if (!authorized) throw new GraphQLYogaError("You don't have permission to modify this post") return Post.find(postId) .then(post => { - if (post.get('proposal_status') !== Post.Proposal_Status.DISCUSSION) throw new GraphQLYogaError("Proposal options cannot be changed unless the proposal is in 'discussion'") // TODO PROPOSALS: edit this for casual proposals - return post.setProposalOptions(options) + if (post.get('proposal_status') !== Post.Proposal_Status.DISCUSSION) throw new GraphQLYogaError("Proposal options cannot be changed unless the proposal is in 'discussion'") + return post.setProposalOptions({ options }) + }) + .catch((err) => { throw new GraphQLYogaError(`setting of options failed: ${err}`) }) + .then(() => ({ success: true })) +} + +export async function updateProposalOptions ({ userId, postId, options }) { + if (!userId || !postId || !options) throw new GraphQLYogaError(`Missing required parameters: ${JSON.stringify({ userId, postId, options })}`) + const authorized = await Post.isVisibleToUser(postId, userId) + if (!authorized) throw new GraphQLYogaError("You don't have permission to modify this post") + return Post.find(postId) + .then(post => { + if (post.get('proposal_status') === Post.Proposal_Status.COMPLETED && post.get('proposal_status') !== Post.Proposal_Status.CASUAL) throw new GraphQLYogaError("Proposal options cannot be changed unless the proposal is in 'discussion'") + // TODO PROPOSALS: discard votes if options are changed + return post.updateProposalOptions({ options, userId, opts: { transacting: null, require: false } }) }) .catch((err) => { throw new GraphQLYogaError(`setting of options failed: ${err}`) }) .then(() => ({ success: true })) @@ -103,7 +116,7 @@ export async function swapProposalVote ({ userId, postId, removeOptionId, addOpt const post = await Post.find(postId) if (!post) throw new GraphQLYogaError(`Couldn't find post for ${postId}`) - if (post.get('proposal_status') !== Post.Proposal_Status.VOTING) throw new GraphQLYogaError('Cannot vote on a proposal that is in discussion or completed') // TODO PROPOSALS: edit this for casual proposals + if (post.get('proposal_status') !== Post.Proposal_Status.VOTING && post.get('proposal_status') !== Post.Proposal_Status.CASUAL) throw new GraphQLYogaError('Cannot vote on a proposal that is in discussion or completed') try { await post.removeProposalVote({ userId, optionId: removeOptionId }) diff --git a/api/graphql/mutations/post.test.js b/api/graphql/mutations/post.test.js index b53ddffed..034355e7d 100644 --- a/api/graphql/mutations/post.test.js +++ b/api/graphql/mutations/post.test.js @@ -1,6 +1,6 @@ import '../../../test/setup' import factories from '../../../test/setup/factories' -import { pinPost, removeProposalVote, addProposalVote, swapProposalVote, setProposalOptions } from './post' +import { pinPost, removeProposalVote, addProposalVote, swapProposalVote, setProposalOptions, updateProposalOptions } from './post' describe('pinPost', () => { var user, group, post @@ -45,7 +45,7 @@ describe('pinPost', () => { }) describe('ProposalVote', () => { - var user, post, option1, option2, option3, option4, optionId, optionId2, g1 + var user, post, option1, option2, option3, option4, option5, optionId, optionId2, g1 before(function () { user = factories.user() @@ -59,9 +59,10 @@ describe('ProposalVote', () => { option2 = { post_id: post.id, text: 'option2' } option3 = { post_id: post.id, text: 'third' } option4 = { post_id: post.id, text: 'fourth' } + option5 = { post_id: post.id, text: 'five' } await post.save({ proposal_status: Post.Proposal_Status.DISCUSSION }, { patch: true }) - return post.setProposalOptions([option1, option2]) + return post.setProposalOptions({ options: [option1, option2] }) }) .then(async (result) => { const rows = result.filter((res) => (res.command === 'INSERT'))[0].rows @@ -103,7 +104,7 @@ describe('ProposalVote', () => { .catch(e => expect(e).to.match(/You don't have permission to vote on this post/)) }) - it('allows the proposal options to be updated', async () => { + it('allows the proposal options to be set', async () => { await removeProposalVote({ userId: user.id, postId: post.id, optionId: optionId2 }) await post.save({ proposal_status: Post.Proposal_Status.DISCUSSION }, { patch: true }) return setProposalOptions({ userId: user.id, postId: post.id, options: [option3, option4] }) @@ -113,6 +114,18 @@ describe('ProposalVote', () => { }) }) + it('allows the proposal options to be updated', async () => { + await post.save({ proposal_status: Post.Proposal_Status.DISCUSSION }, { patch: true }) + const currentOptions = await post.proposalOptions().fetch() + const option3Model = currentOptions.models[0] + return updateProposalOptions({ userId: user.id, postId: post.id, options: [{ id: option3Model.get('id'), text: option3Model.get('text') }, option5] }) + .then(() => post.proposalOptions().fetch()) + .then(options => { + expect(options.models[0].attributes.text).to.equal(option3.text) + expect(options.models[1].attributes.text).to.equal(option5.text) + }) + }) + it('does not allow proposal options to be updated if the proposal_status is not "discussion"', async () => { await post.save({ proposal_status: Post.Proposal_Status.VOTING }, { patch: true }) return setProposalOptions({ userId: user.id, postId: post.id, options: [option1, option2] }) diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index 578ae315c..0e44e7f0b 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -21,7 +21,7 @@ type Query { # Query for PersonConnections connections( - # Number of PersonConnections to load + # Int of PersonConnections to load first: Int, # Start loading at this offset offset: Int @@ -62,7 +62,7 @@ type Query { farmQuery: JSON, # XXX: NOT USED filter: String, - # Number of groups to return + # Int of groups to return first: Int, # Only find these groups, by ID groupIds: [ID], @@ -107,7 +107,7 @@ type Query { groupTopics( # Return only topics whose name starts with the autocomplete string autocomplete: String, - # Number of GroupTopics to return + # Int of GroupTopics to return first: Int, # If true only return topics that are set to be a default topic in a group that the current logged in user is a member of isDefault: Boolean, @@ -197,7 +197,7 @@ type Query { createdBy: [ID], # Load posts before or after this ID, depending on sort order cursor: ID, - # Only return posts of this type: 'discussion', 'event', 'offer', 'project', 'request' or 'resource' + # Only return posts of this type: 'discussion', 'event', 'offer', 'project', 'proposal', 'request' or 'resource' filter: String, # Number of posts to return first: Int, @@ -223,7 +223,7 @@ type Query { topic: ID, # Return only posts that have any of these topics by topic ID topics: [ID], - # Return only posts that are one of these types: 'discussion', 'event', 'offer', 'project', 'request' and/or 'resource' + # Return only posts that are one of these types: 'discussion', 'event', 'offer', 'project', 'proposal', 'request' and/or 'resource' types: [String] ): PostQuerySet @@ -771,7 +771,7 @@ type Group { collectionToFilterOut: ID, # Start loading posts before or after this ID, depending on sort order cursor: ID, - # Filter posts by a post type: 'discussion', 'event', 'offer', 'project', request', 'resource' + # Filter posts by a post type: 'discussion', 'event', 'offer', 'project', 'proposal', request', 'resource' # a value of 'all' will include all above post types # a value of 'chat' will include all above post types + 'chat' filter: String, @@ -797,7 +797,7 @@ type Group { topic: ID, # Only load posts that have one of these topics by topic id topics: [ID], - # Filter posts by a set of post types: 'discussion', 'event', 'offer', 'project', request', 'resource' + # Filter posts by a set of post types: 'discussion', 'event', 'offer', 'project', 'proposal', request', 'resource' types: [String] ): PostQuerySet @@ -828,7 +828,7 @@ type Group { collectionToFilterOut: ID, # Return posts before or after this post id, depending on sort order cursor: ID, - # Filter posts by a post type: 'discussion', 'event', 'offer', 'project', request', 'resource' + # Filter posts by a post type: 'discussion', 'event', 'offer', 'project', 'proposal', request', 'resource' filter: String, # Load this number of posts first: Int, @@ -850,7 +850,7 @@ type Group { topic: ID, # Only load posts that have one of these topics by topic id topics: [ID], - # Filter posts by a set of post types: 'discussion', 'event', 'offer', 'project', request', 'resource' + # Filter posts by a set of post types: 'discussion', 'event', 'offer', 'project', 'proposal', request', 'resource' types: [String] ): PostQuerySet @@ -1545,7 +1545,7 @@ type Person { createdBy: [ID], # Start loading posts before or after this ID, depending on sort order cursor: ID, - # Filter posts by a post type: 'discussion', 'event', 'offer', 'project', request', 'resource' + # Filter posts by a post type: 'discussion', 'event', 'offer', 'project', 'proposal', request', 'resource' filter: String, # Load this number of posts first: Int, @@ -1572,7 +1572,7 @@ type Person { topic: ID, # Only load posts that have one of these topics by topic id topics: [ID], - # Filter posts by a set of post types: 'discussion', 'event', 'offer', 'project', request', 'resource' + # Filter posts by a set of post types: 'discussion', 'event', 'offer', 'project', 'proposal', request', 'resource' types: [String] ): PostQuerySet @@ -1641,8 +1641,12 @@ type Post { fulfilledAt: Date # Number of groups this has been posted to groupsTotal: Int + # Whether who votes on this proposal is visible or not + isAnonymousVote: Boolean # Whether this post is publicly visible (outside of the groups it is posted to) isPublic: Boolean + # A strict proposal cannot be edited in all the ways a casual proposal can be + isStrictProposal: Boolean # Whether LinkPreview appears full size above post or smaller below linkPreviewFeatured: Boolean # Full location string @@ -1665,7 +1669,9 @@ type Post { proposalStatus: String # Link to a project management tool or service. Only used for Projects right now projectManagementLink: String - # The percentage of membership that need to vote for a proposal to complete successfully + # The type of proposal: 'single', 'multiple-unrestricted', 'majority', 'consensus' + proposalType: String + # Percentage of all group members that must vote for a proposal to complete quorum: Int # Number of total reactions on the post reactionsTotal: Int @@ -1681,7 +1687,7 @@ type Post { topicsTotal: Int # NOT USED RIGHT NOW totalContributions: Int - # Post type: 'discussion', 'event', 'offer', 'project', 'request', or 'resource' + # Post type: 'discussion', 'event', 'offer', 'project', 'proposal', 'request', or 'resource' type: String updatedAt: Date @@ -1778,6 +1784,8 @@ type ProposalVote { optionId: ID # timestamp of when the vote was created createdAt: Date + # The person who voted + user: Person } # A question asked when someone is trying to join a group @@ -2241,6 +2249,8 @@ type Mutation { updateMembership(groupId: ID, slug: String, data: MembershipInput): Membership # Update a post you created updatePost(id: ID, data: PostInput): Post + # update options for a proposal + updateProposalOptions(postId: ID, options: [ProposalOptionInput]): [ProposalOption] # NOT USED RIGHT NOW updateStripeAccount(accountId: String): GenericResult # Update settings for a GroupWidget @@ -2715,8 +2725,12 @@ input PostInput { groupIds: [ID] # URLs of images in the post imageUrls: [String] + # Whether who votes on this proposal is visible or not + isAnonymousVote: Boolean # Whether this post is publicly visible (outside of the groups it is posted to) isPublic: Boolean + # A strict proposal cannot be edited in all the ways a casual proposal can be + isStrictProposal: Boolean # ID of a LinkPreview to show in the post linkPreviewId: String # Whether LinkPreview appears full size above post or smaller below @@ -2729,6 +2743,12 @@ input PostInput { memberIds: [ID] # Link to a project management tool or service. Only used for Projects right now projectManagementLink: String + # The options for this proposal + proposalOptions: [ProposalOptionInput] + # The type of proposal: 'poll', 'survey', 'vote', or 'proposal' + proposalType: String + # Percentage of all group members that must vote for a proposal to complete + quorum: Int # When this post "starts". Used for events, resources, projects, requests and offers right now startTime: Date # The timezone of the post @@ -2737,7 +2757,7 @@ input PostInput { title: String # Topics to add to the post topicNames: [String] - # Post type: 'discussion', 'event', 'offer', 'project', 'request', or 'resource' + # Post type: 'discussion', 'event', 'offer', 'project', 'proposal', 'request', or 'resource' type: String } diff --git a/api/models/Post.js b/api/models/Post.js index 83aa37093..f4f2d8bb9 100644 --- a/api/models/Post.js +++ b/api/models/Post.js @@ -78,7 +78,7 @@ module.exports = bookshelf.Model.extend(Object.assign({ }, isProposal: function () { - return this.get('type') === Post.Type.Proposal + return this.get('type') === Post.Type.PROPOSAL }, isThread: function () { @@ -336,18 +336,19 @@ module.exports = bookshelf.Model.extend(Object.assign({ return vote.destroy() }, - async setProposalOptions (options = []) { + async setProposalOptions ({ options = [], userId, opts = {} }) { + const trxOpts = { require: false, ...opts } return bookshelf.knex.raw(`BEGIN; - DELETE FROM proposal_options - WHERE post_id = ${this.id}; - - INSERT INTO proposal_options (post_id, text, color, emoji) - VALUES - ${options.map(option => `(${this.id}, '${option.text}', '${option.color}', '${option.emoji}')`).join(', ')} - RETURNING id; + DELETE FROM proposal_options + WHERE post_id = ${this.id}; - COMMIT;`) + INSERT INTO proposal_options (post_id, text, color, emoji) + VALUES + ${options.map(option => `(${this.id}, '${option.text}', '${option.color || ''}', '${option.emoji || ''}')`).join(', ')} + RETURNING id; + + COMMIT;`).transacting(trxOpts) }, async swapProposalVote ({ userId, removeOptionId, addOptionId }) { @@ -361,7 +362,41 @@ module.exports = bookshelf.Model.extend(Object.assign({ const existingFollowers = await this.postUsers() .query(q => q.whereIn('user_id', userIds)).fetch({ transacting }) const updatedAttribs = pick(POSTS_USERS_ATTR_UPDATE_WHITELIST, omitBy(isUndefined, attrs)) - return Promise.map(existingFollowers.models, postUser => postUser.updateAndSave(updatedAttribs, {transacting})) + return Promise.map(existingFollowers.models, postUser => postUser.updateAndSave(updatedAttribs, { transacting })) + }, + + async updateProposalOptions ({ options = [], userId, opts = { } }) { + const existingOptions = await this.proposalOptions().fetch({ transaction: opts.transacting, require: false }) + const existingOptionIds = existingOptions.pluck('id') + + // Delete ALL votes any time options are updated + if (options.length > 0 && existingOptionIds.length > 0) { + const deleteVotesQuery = ` + DELETE FROM proposal_votes + WHERE option_id IN (${existingOptionIds.join(', ')}); + ` + await bookshelf.knex.raw(deleteVotesQuery).transacting({ transaction: opts.transacting, require: false }) + } + // Delete all options and start fresh + if (options.length > 0 && existingOptionIds.length > 0) { + const deleteQuery = ` + DELETE FROM proposal_options + WHERE id IN (${existingOptionIds.join(', ')}); + ` + await bookshelf.knex.raw(deleteQuery).transacting({ transaction: opts.transacting, require: false }) + } + + // Execute the insert query for options passed in + if (options.length > 0) { + const insertQuery = ` + INSERT INTO proposal_options (post_id, text, color, emoji) + VALUES ${options.map(option => `(${this.id}, '${option.text}', '${option.color}', '${option.emoji}')`).join(', ')}; + ` + await bookshelf.knex.raw(insertQuery).transacting({ transaction: opts.transacting, require: false }) + } + + // Return a resolved promise + return Promise.resolve() }, async markAsRead (userId) { @@ -552,12 +587,14 @@ module.exports = bookshelf.Model.extend(Object.assign({ Proposal_Status: { DISCUSSION: 'discussion', COMPLETED: 'completed', - VOTING: 'voting' + VOTING: 'voting', + CASUAL: 'casual' }, Proposal_Outcome: { CANCELLED: 'cancelled', QUORUM_NOT_MET: 'quorum-not-met', + INCOMPLETE: 'incomplete', IN_PROGRESS: 'in-progress', SUCCESSFUL: 'successful', TIE: 'tie' @@ -566,8 +603,8 @@ module.exports = bookshelf.Model.extend(Object.assign({ Proposal_Type: { SINGLE: 'single', MULTI_UNRESTRICTED: 'multi-unrestricted', - CONSENSUS: 'consensus', // Stricter form of single vote, all votes must be for the same option to 'pass' - MAJORITY: 'majority' // one option must have more than 50% of votes for the proposal to 'pass' + MAJORITY: 'majority', // one option must have more than 50% of votes for the proposal to 'pass', + CONSENSUS: 'consensus' // Will not pass if there are any block votes }, // TODO Consider using Visibility property for more granular privacy @@ -648,6 +685,7 @@ module.exports = bookshelf.Model.extend(Object.assign({ }), create: function (attrs, opts) { + console.log(attrs,'attrs in create') return Post.forge(_.merge(Post.newPostAttrs(), attrs)) .save(null, _.pick(opts, 'transacting')) }, @@ -695,36 +733,58 @@ module.exports = bookshelf.Model.extend(Object.assign({ fixTypedPosts: () => bookshelf.transaction(transacting => Tag.whereIn('name', ['request', 'offer', 'resource', 'intention']) - .fetchAll({transacting}) - .then(tags => Post.query(q => { - q.whereIn('type', ['request', 'offer', 'resource', 'intention']) - }).fetchAll({withRelated: ['selectedTags', 'tags'], transacting}) - .then(posts => Promise.each(posts.models, post => { - const untype = () => post.save({type: null}, {patch: true, transacting}) - if (post.relations.selectedTags.first()) return untype() - - const matches = t => t.get('name') === post.get('type') - const existingTag = post.relations.tags.find(matches) - if (existingTag) { - return PostTag.query() - .where({post_id: post.id, tag_id: existingTag.id}) - .update({selected: true}).transacting(transacting) - .then(untype) - } - - return post.selectedTags().attach(tags.find(matches).id, {transacting}) - .then(untype) - })) - .then(promises => promises.length))), + .fetchAll({ transacting }) + .then(tags => Post.query(q => { + q.whereIn('type', ['request', 'offer', 'resource', 'intention']) + }).fetchAll({ withRelated: ['selectedTags', 'tags'], transacting }) + .then(posts => Promise.each(posts.models, post => { + const untype = () => post.save({ type: null }, { patch: true, transacting }) + if (post.relations.selectedTags.first()) return untype() + + const matches = t => t.get('name') === post.get('type') + const existingTag = post.relations.tags.find(matches) + if (existingTag) { + return PostTag.query() + .where({ post_id: post.id, tag_id: existingTag.id }) + .update({ selected: true }).transacting(transacting) + .then(untype) + } + + return post.selectedTags().attach(tags.find(matches).id, { transacting }) + .then(untype) + })) + .then(promises => promises.length))), // TODO: does this work? notifySlack: ({ postId }) => - Post.find(postId, {withRelated: ['groups', 'user', 'relatedUsers']}) - .then(post => { - if (!post) return - const slackCommunities = post.relations.groups.filter(g => g.get('slack_hook_url')) - return Promise.map(slackCommunities, g => Group.notifySlack(g.id, post)) - }), + Post.find(postId, { withRelated: ['groups', 'user', 'relatedUsers'] }) + .then(post => { + if (!post) return + const slackCommunities = post.relations.groups.filter(g => g.get('slack_hook_url')) + return Promise.map(slackCommunities, g => Group.notifySlack(g.id, post)) + }), + + updateProposalStatuses: async () => { + return bookshelf.knex.raw( + `UPDATE posts + SET proposal_status = + CASE + WHEN proposal_status NOT IN ('casual', 'completed') + AND type = 'proposal' + AND CURRENT_TIMESTAMP BETWEEN start_time AND end_time + THEN 'voting' + WHEN proposal_status NOT IN ('casual', 'completed') + AND type = 'proposal' + AND CURRENT_TIMESTAMP > end_time + THEN 'completed' + ELSE proposal_status + END + WHERE type = 'proposal' + AND proposal_status NOT IN ('casual', 'completed') + AND start_time IS NOT NULL + AND end_time IS NOT NULL;` + ) + }, // Background task to fire zapier triggers on new_post zapierTriggers: async ({ postId }) => { diff --git a/api/models/post/createPost.js b/api/models/post/createPost.js index 45e63c732..63eb5ca38 100644 --- a/api/models/post/createPost.js +++ b/api/models/post/createPost.js @@ -10,12 +10,11 @@ export default async function createPost (userId, params) { const allowedToMakePublic = groups.find(g => g.get('allow_in_public')) if (!allowedToMakePublic) params.isPublic = false } - - return setupPostAttrs(userId, merge(Post.newPostAttrs(), params)) + return setupPostAttrs(userId, merge(Post.newPostAttrs(), params), true) .then(attrs => bookshelf.transaction(transacting => Post.create(attrs, { transacting }) .tap(post => afterCreatingPost(post, merge( - pick(params, 'group_ids', 'imageUrl', 'videoUrl', 'docs', 'topicNames', 'memberIds', 'eventInviteeIds', 'imageUrls', 'fileUrls', 'announcement', 'location', 'location_id'), + pick(params, 'group_ids', 'imageUrl', 'videoUrl', 'docs', 'topicNames', 'memberIds', 'eventInviteeIds', 'imageUrls', 'fileUrls', 'announcement', 'location', 'location_id', 'proposalOptions'), {children: params.requests, transacting} )))).then(function(inserts) { return inserts @@ -31,7 +30,6 @@ export function afterCreatingPost (post, opts) { const followerIds = uniq(mentioned.concat(userId)) const trx = opts.transacting const trxOpts = pick(opts, 'transacting') - return Promise.all(flatten([ opts.group_ids && post.groups().attach(uniq(opts.group_ids), trxOpts), @@ -80,11 +78,11 @@ export function afterCreatingPost (post, opts) { type: 'video', url: opts.videoUrl }, trx), - opts.docs && Promise.map(opts.docs, (doc) => Media.createDoc(post.id, doc, trx)), ])) .then(() => post.isProject() && post.setProjectMembers(opts.memberIds || [], trxOpts)) .then(() => post.isEvent() && post.updateEventInvitees(opts.eventInviteeIds || [], userId, trxOpts)) + .then(() => post.isProposal() && post.setProposalOptions({ options: opts.proposalOptions || [], userId, opts: trxOpts })) .then(() => Tag.updateForPost(post, opts.topicNames, userId, trx)) .then(() => updateTagsAndGroups(post, trx)) .then(() => Queue.classMethod('Post', 'createActivities', { postId: post.id })) diff --git a/api/models/post/setupPostAttrs.js b/api/models/post/setupPostAttrs.js index 3406de9c2..19efc080c 100644 --- a/api/models/post/setupPostAttrs.js +++ b/api/models/post/setupPostAttrs.js @@ -1,9 +1,10 @@ import { merge, pick } from 'lodash' import { getOr } from 'lodash/fp' -export default function setupPostAttrs (userId, params) { +export default function setupPostAttrs (userId, params, create = false) { const attrs = merge({ accept_contributions: params.acceptContributions, + anonymous_voting: params.isAnonymousVote, announcement: params.announcement, donations_link: params.donationsLink, end_time: params.endTime ? new Date(Number(params.endTime)) : null, @@ -11,6 +12,7 @@ export default function setupPostAttrs (userId, params) { link_preview_id: params.link_preview_id || getOr(null, 'id', params.linkPreview), parent_post_id: params.parent_post_id, project_management_link: params.projectManagementLink, + proposal_type: params.proposalType, start_time: params.startTime ? new Date(Number(params.startTime)) : null, updated_at: new Date(), user_id: userId @@ -21,9 +23,27 @@ export default function setupPostAttrs (userId, params) { 'location_id', 'location', 'name', + 'quorum', 'timezone', 'type' )) - return Promise.resolve(attrs) + let proposalAttrs = {} + let proposalStatus = params.startTime && new Date(Number(params.startTime)) < new Date() ? Post.Proposal_Status.VOTING : Post.Proposal_Status.DISCUSSION + if (params.endTime && new Date(Number(params.endTime)) < new Date()) proposalStatus = Post.Proposal_Status.COMPLETED + if (create) { + // if the startTime of the post is set and its before the current time in that timezone, then set the proposal status to VOTING + proposalAttrs = { + proposal_outcome: Post.Proposal_Outcome.INPROGRESS, + proposal_strict: params.isStrictProposal, + proposal_status: params.startTime ? proposalStatus : Post.Proposal_Status.CASUAL + } + } else { + proposalAttrs = { + proposal_status: proposalStatus + + } + } + + return Promise.resolve({ ...attrs, ...proposalAttrs }) } diff --git a/api/models/post/updatePost.js b/api/models/post/updatePost.js index 53e4fba0f..6cfd08686 100644 --- a/api/models/post/updatePost.js +++ b/api/models/post/updatePost.js @@ -10,31 +10,32 @@ import { export default function updatePost (userId, id, params) { if (!id) throw new GraphQLYogaError('updatePost called with no ID') return setupPostAttrs(userId, params) - .then(attrs => bookshelf.transaction(transacting => - Post.find(id).then(post => { - if (!post) throw new GraphQLYogaError('Post not found') - const updatableTypes = [ - Post.Type.CHAT, - Post.Type.DISCUSSION, - Post.Type.EVENT, - Post.Type.OFFER, - Post.Type.PROJECT, - Post.Type.REQUEST, - Post.Type.RESOURCE - ] - if (!updatableTypes.includes(post.get('type'))) { - throw new GraphQLYogaError("This post can't be modified") - } + .then(attrs => bookshelf.transaction(transacting => + Post.find(id).then(post => { + if (!post) throw new GraphQLYogaError('Post not found') + const updatableTypes = [ + Post.Type.CHAT, + Post.Type.DISCUSSION, + Post.Type.EVENT, + Post.Type.OFFER, + Post.Type.PROJECT, + Post.Type.PROPOSAL, + Post.Type.REQUEST, + Post.Type.RESOURCE + ] + if (!updatableTypes.includes(post.get('type'))) { + throw new GraphQLYogaError("This post can't be modified") + } - return post.save(attrs, {patch: true, transacting}) - .tap(updatedPost => afterUpdatingPost(updatedPost, {params, userId, transacting})) - }))) + return post.save(attrs, { patch: true, transacting }) + .tap(updatedPost => afterUpdatingPost(updatedPost, { params, userId, transacting })) + }))) } export function afterUpdatingPost (post, opts) { const { params, - params: { requests, group_ids, topicNames, memberIds, eventInviteeIds }, + params: { requests, group_ids, topicNames, memberIds, eventInviteeIds, proposalOptions }, userId, transacting } = opts @@ -49,4 +50,5 @@ export function afterUpdatingPost (post, opts) { ])) .then(() => post.get('type') === 'project' && memberIds && post.setProjectMembers(memberIds, { transacting })) .then(() => post.get('type') === 'event' && eventInviteeIds && post.updateEventInvitees(eventInviteeIds, userId, { transacting })) + .then(() => post.get('type') === 'proposal' && proposalOptions && post.updateProposalOptions({ options: proposalOptions, userId, opts: { transacting } })) } diff --git a/api/models/post/validatePostData.js b/api/models/post/validatePostData.js index cc7a12173..8a272d3e7 100644 --- a/api/models/post/validatePostData.js +++ b/api/models/post/validatePostData.js @@ -2,11 +2,15 @@ const { GraphQLYogaError } = require('@graphql-yoga/node') import { includes, isEmpty, trim } from 'lodash' export default function validatePostData (userId, data) { - const allowedTypes = [Post.Type.CHAT, Post.Type.REQUEST, Post.Type.OFFER, Post.Type.DISCUSSION, Post.Type.PROJECT, Post.Type.EVENT, Post.Type.RESOURCE] + const allowedTypes = [Post.Type.CHAT, Post.Type.REQUEST, Post.Type.OFFER, Post.Type.DISCUSSION, Post.Type.PROJECT, Post.Type.EVENT, Post.Type.RESOURCE, Post.Type.PROPOSAL] if (data.type && !includes(allowedTypes, data.type)) { throw new GraphQLYogaError('not a valid type') } + if (data.type === Post.Type.PROPOSAL && data.proposalOptions && data.proposalOptions.length === 0) { + throw new GraphQLYogaError('Proposals need at a least one option') + } + if (isEmpty(data.group_ids)) { throw new GraphQLYogaError('no groups specified') } diff --git a/api/services/Search/util.js b/api/services/Search/util.js index a0d891f7c..4b86318b7 100644 --- a/api/services/Search/util.js +++ b/api/services/Search/util.js @@ -48,7 +48,7 @@ export const filterAndSortPosts = curry((opts, q) => { } } - const { CHAT, DISCUSSION, REQUEST, OFFER, PROJECT, EVENT, RESOURCE } = Post.Type + const { CHAT, DISCUSSION, REQUEST, OFFER, PROJECT, EVENT, RESOURCE, PROPOSAL } = Post.Type if (isAnnouncement) { q.where('announcement', true).andWhere('posts.created_at', '>=', moment().subtract(1, 'month').toDate()) @@ -104,9 +104,9 @@ export const filterAndSortPosts = curry((opts, q) => { if (types) { q.whereIn('posts.type', types) } else if (type === 'chat') { - q.whereIn('posts.type', [CHAT, DISCUSSION, REQUEST, OFFER, PROJECT, EVENT, RESOURCE]) + q.whereIn('posts.type', [CHAT, DISCUSSION, REQUEST, OFFER, PROJECT, PROPOSAL, EVENT, RESOURCE]) } else if (!type || type === 'all' || type === 'all+welcome') { - q.whereIn('posts.type', [DISCUSSION, REQUEST, OFFER, PROJECT, EVENT, RESOURCE]) + q.whereIn('posts.type', [DISCUSSION, REQUEST, OFFER, PROJECT, PROPOSAL, EVENT, RESOURCE]) } else { if (!includes(values(Post.Type), type)) { throw new GraphQLYogaError(`unknown post type: "${type}"`) diff --git a/api/services/Search/util.test.js b/api/services/Search/util.test.js index eb4e6a442..820046154 100644 --- a/api/services/Search/util.test.js +++ b/api/services/Search/util.test.js @@ -59,7 +59,7 @@ describe('filterAndSortPosts', () => { it('includes basic types when filter is blank', () => { filterAndSortPosts({}, query) expectEqualQuery(relation, `select * from "posts" - where "posts"."type" in ('discussion', 'request', 'offer', 'project', 'event', 'resource') + where "posts"."type" in ('discussion', 'request', 'offer', 'project', 'proposal', 'event', 'resource') order by "posts"."updated_at" desc`) }) }) diff --git a/cron.js b/cron.js index 3b614aa1b..292d5b9f4 100644 --- a/cron.js +++ b/cron.js @@ -68,11 +68,12 @@ const hourly = now => { } const every10minutes = now => { - sails.log.debug('Refreshing full-text search index, sending comment digests, updating member counts') + sails.log.debug('Refreshing full-text search index, sending comment digests, updating member counts, and updating proposal statuses') return [ FullTextSearch.refreshView(), Comment.sendDigests().then(count => sails.log.debug(`Sent ${count} comment/message digests`)), - Group.updateAllMemberCounts() + Group.updateAllMemberCounts(), + Post.updateProposalStatuses() ] } diff --git a/lib/group/digest2/formatData.js b/lib/group/digest2/formatData.js index 8b222d92f..0a17f8d5d 100644 --- a/lib/group/digest2/formatData.js +++ b/lib/group/digest2/formatData.js @@ -10,6 +10,7 @@ const isOffer = post => post.get('type') === 'offer' const isResource = post => post.get('type') === 'resource' const isEvent = post => post.get('type') === 'event' const isProject = post => post.get('type') === 'project' +const isProposal = post => post.get('type') === 'proposal' const isDiscussion = post => post.get('type') === 'discussion' export const presentAuthor = obj => @@ -46,6 +47,7 @@ const formatData = curry((group, data) => { const resources = map(presentPost(slug), filter(isResource, data.posts)) const events = map(presentPost(slug), filter(isEvent, data.posts)) const projects = map(presentPost(slug), filter(isProject, data.posts)) + const proposals = map(presentPost(slug), filter(isProposal, data.posts)) const discussions = map(presentPost(slug), filter(isDiscussion, data.posts)) const chats = map(presentPost(slug), filter(isChat, data.posts)) const postsWithNewComments = [] @@ -65,7 +67,7 @@ const formatData = curry((group, data) => { }, []) const findFormattedPost = id => find(p => p.id === id, - requests.concat(offers).concat(discussions).concat(projects).concat(events).concat(resources).concat(chats).concat(postsWithNewComments)) + requests.concat(offers).concat(discussions).concat(projects).concat(proposals).concat(events).concat(resources).concat(chats).concat(postsWithNewComments)) data.comments.forEach(comment => { let post = findFormattedPost(comment.get('post_id')) @@ -87,11 +89,12 @@ const formatData = curry((group, data) => { discussions: sortBy(p => -p.id, discussions), events: sortBy(p => -p.id, events), projects: sortBy(p => -p.id, projects), + proposals: sortBy(p => -p.id, proposals), postsWithNewComments: sortBy(p => -p.id, postsWithNewComments), topicsWithChats: Object.values(topicsWithChats) } - if (every(isEmpty, [requests, offers, resources, discussions, events, projects, topicsWithChats, postsWithNewComments])) { + if (every(isEmpty, [requests, offers, resources, discussions, events, projects, proposals, topicsWithChats, postsWithNewComments])) { // this is used in the email templates to change the subject // XXX: but if there is no activity, why send the email at all? // Only makes sense if we are sending one a day with the form asking people what they need, which we could bring back diff --git a/migrations/20240218134105_proposals.js b/migrations/20240218134105_proposals.js index f9ea3194c..37a0aef52 100644 --- a/migrations/20240218134105_proposals.js +++ b/migrations/20240218134105_proposals.js @@ -29,7 +29,7 @@ exports.up = async function (knex, Promise) { table.timestamp('created_at') }) - await knex.raw('alter table proposal_options alter constraint proposal_options_post_id_foreign deferrable initially deferred') + // await knex.raw('alter table proposal_options alter constraint proposal_options_post_id_foreign deferrable initially deferred') await knex.raw('alter table proposal_votes alter constraint proposal_votes_post_id_foreign deferrable initially deferred') await knex.raw('alter table proposal_votes alter constraint proposal_votes_option_id_foreign deferrable initially deferred') await knex.raw('alter table proposal_votes alter constraint proposal_votes_user_id_foreign deferrable initially deferred') @@ -44,6 +44,7 @@ exports.down = async function (knex, Promise) { table.dropIndex(['proposal_outcome']) table.dropColumn('quorum') table.dropColumn('proposal_status') + table.dropColumn('proposal_strict') table.dropColumn('proposal_type') table.dropColumn('proposal_outcome') table.dropColumn('anonymous_voting') diff --git a/migrations/schema.sql b/migrations/schema.sql index 433ba3754..b157a3202 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -1815,6 +1815,7 @@ CREATE TABLE public.posts ( timezone character varying(255), quorum bigint, proposal_status text, + proposal_strict boolean DEFAULT false, proposal_outcome text, proposal_type text, anonymous_voting text, diff --git a/test/unit/services/Search.test.js b/test/unit/services/Search.test.js index 0ed2e640f..d56eaf6fd 100644 --- a/test/unit/services/Search.test.js +++ b/test/unit/services/Search.test.js @@ -48,12 +48,12 @@ describe('Search', function () { it('includes only basic post types by default', () => { var query = Search.forPosts({groups: 9}).query().toString() - expect(query).to.contain('"posts"."type" in (\'discussion\', \'request\', \'offer\', \'project\', \'event\', \'resource\')') + expect(query).to.contain('"posts"."type" in (\'discussion\', \'request\', \'offer\', \'project\', \'proposal\', \'event\', \'resource\')') }) it('includes only basic post types when type is "all"', () => { var query = Search.forPosts({groups: 9, type: 'all'}).query().toString() - expect(query).to.contain('"posts"."type" in (\'discussion\', \'request\', \'offer\', \'project\', \'event\', \'resource\')') + expect(query).to.contain('"posts"."type" in (\'discussion\', \'request\', \'offer\', \'project\', \'proposal\', \'event\', \'resource\')') }) it('accepts an option to change the name of the total column', () => { diff --git a/test/unit/services/digest2.test.js b/test/unit/services/digest2.test.js index a621a66b9..fef78a203 100644 --- a/test/unit/services/digest2.test.js +++ b/test/unit/services/digest2.test.js @@ -194,6 +194,7 @@ describe('group digest v2', () => { } ], resources: [], + proposals: [], discussions: [ { id: 7, @@ -285,6 +286,7 @@ describe('group digest v2', () => { expect(formatData(group, data)).to.deep.equal({ offers: [], discussions: [], + proposals: [], requests: [ { id: 1, @@ -313,6 +315,7 @@ describe('group digest v2', () => { offers: [], requests: [], discussions: [], + proposals: [], postsWithNewComments: [], projects: [], events: [], @@ -466,6 +469,7 @@ describe('group digest v2', () => { topicsWithChats: [], events: [], projects: [], + proposals: [], resources: [], discussions: [ { From 3c199c621d6727c8c91578b290f64b69044de1ff Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 2 May 2024 14:43:19 -0700 Subject: [PATCH 08/18] Ensure only proposal posts query for options and votes --- api/graphql/mutations/post.test.js | 2 +- api/graphql/schema.graphql | 2 +- api/models/Post.js | 14 ++++++++------ api/models/post/setupPostAttrs.js | 2 +- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/api/graphql/mutations/post.test.js b/api/graphql/mutations/post.test.js index 034355e7d..11d8251fe 100644 --- a/api/graphql/mutations/post.test.js +++ b/api/graphql/mutations/post.test.js @@ -49,7 +49,7 @@ describe('ProposalVote', () => { before(function () { user = factories.user() - post = factories.post() + post = factories.post({ type: 'proposal' }) g1 = factories.group({ active: true }) return Promise.join(user.save(), post.save(), g1.save()) .then(() => user.joinGroup(g1)) diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index 0e44e7f0b..07c7da732 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -21,7 +21,7 @@ type Query { # Query for PersonConnections connections( - # Int of PersonConnections to load + # Number of PersonConnections to load first: Int, # Start loading at this offset offset: Int diff --git a/api/models/Post.js b/api/models/Post.js index f4f2d8bb9..d9138e4fb 100644 --- a/api/models/Post.js +++ b/api/models/Post.js @@ -164,11 +164,13 @@ module.exports = bookshelf.Model.extend(Object.assign({ }, proposalOptions: function () { - return this.hasMany(ProposalOption) + // return this.hasMany(ProposalOption) + return this.get('type') === Post.Type.PROPOSAL ? this.hasMany(ProposalOption) : null }, proposalVotes: function () { - return this.hasMany(ProposalVote) + // return this.hasMany(ProposalVote) + return this.get('type') === Post.Type.PROPOSAL ? this.hasMany(ProposalVote) : null }, reactions: function () { @@ -602,9 +604,10 @@ module.exports = bookshelf.Model.extend(Object.assign({ Proposal_Type: { SINGLE: 'single', - MULTI_UNRESTRICTED: 'multi-unrestricted', - MAJORITY: 'majority', // one option must have more than 50% of votes for the proposal to 'pass', - CONSENSUS: 'consensus' // Will not pass if there are any block votes + MULTI_UNRESTRICTED: 'multi-unrestricted' + // unused for now + // MAJORITY: 'majority', // one option must have more than 50% of votes for the proposal to 'pass', + // CONSENSUS: 'consensus' // Will not pass if there are any block votes }, // TODO Consider using Visibility property for more granular privacy @@ -685,7 +688,6 @@ module.exports = bookshelf.Model.extend(Object.assign({ }), create: function (attrs, opts) { - console.log(attrs,'attrs in create') return Post.forge(_.merge(Post.newPostAttrs(), attrs)) .save(null, _.pick(opts, 'transacting')) }, diff --git a/api/models/post/setupPostAttrs.js b/api/models/post/setupPostAttrs.js index 19efc080c..6443dcee2 100644 --- a/api/models/post/setupPostAttrs.js +++ b/api/models/post/setupPostAttrs.js @@ -34,7 +34,7 @@ export default function setupPostAttrs (userId, params, create = false) { if (create) { // if the startTime of the post is set and its before the current time in that timezone, then set the proposal status to VOTING proposalAttrs = { - proposal_outcome: Post.Proposal_Outcome.INPROGRESS, + proposal_outcome: Post.Proposal_Outcome.IN_PROGRESS, proposal_strict: params.isStrictProposal, proposal_status: params.startTime ? proposalStatus : Post.Proposal_Status.CASUAL } From f0ba47378603eda781c1b336b05dd58849130bbb Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 8 May 2024 17:00:56 -0700 Subject: [PATCH 09/18] Make activities when votes are reset --- api/graphql/mutations/post.js | 6 +++--- api/graphql/mutations/post.test.js | 2 +- api/models/Activity.js | 8 ++++---- api/models/Notification.js | 2 ++ api/models/Post.js | 19 +++++++++++++++++++ api/models/ProposalVote.js | 8 ++++++-- api/models/PushNotification.js | 11 ++++++++--- api/models/post/setupPostAttrs.js | 3 +-- lib/i18n/en.js | 3 ++- lib/i18n/es.js | 3 ++- 10 files changed, 48 insertions(+), 17 deletions(-) diff --git a/api/graphql/mutations/post.js b/api/graphql/mutations/post.js index 52c231a6d..91b86a1a6 100644 --- a/api/graphql/mutations/post.js +++ b/api/graphql/mutations/post.js @@ -59,8 +59,9 @@ export async function addProposalVote ({ userId, postId, optionId }) { if (!authorized) throw new GraphQLYogaError("You don't have permission to vote on this post") return Post.find(postId) - .then(post => { + .then(async post => { if (post.get('proposal_status') !== Post.Proposal_Status.VOTING && post.get('proposal_status') !== Post.Proposal_Status.CASUAL) throw new GraphQLYogaError('Cannot vote on a proposal that is in discussion or completed') + await post.addFollowers([userId]) return post.addProposalVote({ userId, optionId }) }) .catch((err) => { throw new GraphQLYogaError(`adding of vote failed: ${err}`) }) @@ -100,8 +101,7 @@ export async function updateProposalOptions ({ userId, postId, options }) { if (!authorized) throw new GraphQLYogaError("You don't have permission to modify this post") return Post.find(postId) .then(post => { - if (post.get('proposal_status') === Post.Proposal_Status.COMPLETED && post.get('proposal_status') !== Post.Proposal_Status.CASUAL) throw new GraphQLYogaError("Proposal options cannot be changed unless the proposal is in 'discussion'") - // TODO PROPOSALS: discard votes if options are changed + if (post.get('proposal_status') === Post.Proposal_Status.COMPLETED && post.get('proposal_status') !== Post.Proposal_Status.CASUAL) throw new GraphQLYogaError("Proposal options cannot be changed once a proposal is complete'") return post.updateProposalOptions({ options, userId, opts: { transacting: null, require: false } }) }) .catch((err) => { throw new GraphQLYogaError(`setting of options failed: ${err}`) }) diff --git a/api/graphql/mutations/post.test.js b/api/graphql/mutations/post.test.js index 11d8251fe..70deba0a4 100644 --- a/api/graphql/mutations/post.test.js +++ b/api/graphql/mutations/post.test.js @@ -1,6 +1,6 @@ import '../../../test/setup' import factories from '../../../test/setup/factories' -import { pinPost, removeProposalVote, addProposalVote, swapProposalVote, setProposalOptions, updateProposalOptions } from './post' +import { pinPost, removeProposalVote, addProposalVote, swapProposalVote,setProposalOptions, updateProposalOptions } from './post' describe('pinPost', () => { var user, group, post diff --git a/api/models/Activity.js b/api/models/Activity.js index baf75a439..ff12f9132 100644 --- a/api/models/Activity.js +++ b/api/models/Activity.js @@ -235,8 +235,8 @@ module.exports = bookshelf.Model.extend({ }, unreadCountForUser: function (user) { - return Activity.query().where({reader_id: user.id, unread: true}).count() - .then(rows => rows[0].count) + return Activity.query().where({ reader_id: user.id, unread: true }).count() + .then(rows => rows[0].count) }, saveForReasonsOpts: function ({ activities }) { @@ -248,12 +248,12 @@ module.exports = bookshelf.Model.extend({ const attrs = Object.assign( {}, omit(activity, 'reasons'), - {meta: {reasons: activity.reasons}} + { meta: { reasons: activity.reasons } } ) return Activity.createWithNotifications(attrs, trx) }) - .tap(() => Queue.classMethod('Notification', 'sendUnsent')) + .tap(() => Queue.classMethod('Notification', 'sendUnsent')) }, groupIds: function (activity) { diff --git a/api/models/Notification.js b/api/models/Notification.js index 332b125ee..8f8209d8e 100644 --- a/api/models/Notification.js +++ b/api/models/Notification.js @@ -132,6 +132,8 @@ module.exports = bookshelf.Model.extend({ return this.sendPushDonationTo() case 'donation from': return this.sendPushDonationFrom() + case 'voteReset': + return this.sendPostPush('voteReset') default: return Promise.resolve() } diff --git a/api/models/Post.js b/api/models/Post.js index d9138e4fb..fe8471318 100644 --- a/api/models/Post.js +++ b/api/models/Post.js @@ -371,6 +371,11 @@ module.exports = bookshelf.Model.extend(Object.assign({ const existingOptions = await this.proposalOptions().fetch({ transaction: opts.transacting, require: false }) const existingOptionIds = existingOptions.pluck('id') + // Add activities for vote reset + if (options.length > 0 && existingOptionIds.length > 0) { + await this.createVoteResetActivities(opts.transacting) + } + // Delete ALL votes any time options are updated if (options.length > 0 && existingOptionIds.length > 0) { const deleteVotesQuery = ` @@ -492,6 +497,20 @@ module.exports = bookshelf.Model.extend(Object.assign({ return Activity.saveForReasons(readers, trx) }, + createVoteResetActivities: async function (trx) { + const voterIds = await ProposalVote.getVoterIdsForPost(this.id).fetchAll({ transacting: trx }) + if (!voterIds || voterIds.length === 0) return Promise.resolve() + + const voters = voterIds.map(voterId => ({ + reader_id: voterId.get('user_id'), + post_id: this.id, + actor_id: this.get('user_id'), + reason: 'voteReset' + })) + + return Activity.saveForReasons(voters, trx) + }, + fulfill, unfulfill, diff --git a/api/models/ProposalVote.js b/api/models/ProposalVote.js index 9eeb92587..37bea67cc 100644 --- a/api/models/ProposalVote.js +++ b/api/models/ProposalVote.js @@ -11,7 +11,11 @@ module.exports = bookshelf.Model.extend({ user: function () { return this.belongsTo(User, 'user_id') } -}, { - +}, { + getVoterIdsForPost: function (postId) { + return this.query(qb => { + qb.distinct().select('user_id').where('post_id', postId) + }) + } }) diff --git a/api/models/PushNotification.js b/api/models/PushNotification.js index bbe8136fc..1a6799bbd 100644 --- a/api/models/PushNotification.js +++ b/api/models/PushNotification.js @@ -71,9 +71,14 @@ module.exports = bookshelf.Model.extend({ const postName = decode(post.summary()) const groupName = group.get('name') - return version === 'mention' - ? locales[locale].textForPostMention({ groupName, person, postName }) - : locales[locale].textForPost({ person, postName, groupName, firstTag }) + switch (version) { + case 'mention': + return locales[locale].textForPostMention({ groupName, person, postName }) + case 'voteReset': + return locales[locale].textForVoteReset({ person, postName, groupName }) + default: + return locales[locale].textForPost({ person, postName, groupName, firstTag }) + } }, textForAnnouncement: function (post, group, locale) { diff --git a/api/models/post/setupPostAttrs.js b/api/models/post/setupPostAttrs.js index 6443dcee2..20955afb2 100644 --- a/api/models/post/setupPostAttrs.js +++ b/api/models/post/setupPostAttrs.js @@ -40,8 +40,7 @@ export default function setupPostAttrs (userId, params, create = false) { } } else { proposalAttrs = { - proposal_status: proposalStatus - + proposal_status: params.startTime ? proposalStatus : Post.Proposal_Status.CASUAL } } diff --git a/lib/i18n/en.js b/lib/i18n/en.js index 789f6d2b2..5219375cf 100644 --- a/lib/i18n/en.js +++ b/lib/i18n/en.js @@ -34,5 +34,6 @@ exports.en = { textForGroupParentGroupJoinRequestAcceptedChildMember: ({parentGroup, childGroup}) => `Your group ${childGroup.name} has joined ${parentGroup.name}.`, textForJoinRequest: ({actor, groupName}) => `${actor.get('name')} asked to join ${groupName}`, textForPostMention: ({ groupName, person, postName }) => `${person} mentioned you in post "${postName}" in ${groupName}`, - textForPost: ({ firstTag, groupName, person, postName }) => `${person} posted "${postName}" in ${groupName}${firstTag ? ` #${firstTag}` : ''}` + textForPost: ({ firstTag, groupName, person, postName }) => `${person} posted "${postName}" in ${groupName}${firstTag ? ` #${firstTag}` : ''}`, + textForVoteReset: ({ person, postName, groupName }) => `${person} changed the options for proposal: "${postName}" in ${groupName}. This has reset the votes` } diff --git a/lib/i18n/es.js b/lib/i18n/es.js index bf6387b98..20116d958 100644 --- a/lib/i18n/es.js +++ b/lib/i18n/es.js @@ -35,5 +35,6 @@ exports.es = { newSavedSearchResults: (name) => `Nuevos resultados de búsqueda guardados en ${name}`, recentActivityFrom: (name) => `Actividad reciente de ${name}`, textForPostMention: ({ groupName, person, postName }) => `${person} te mencionó en "${postName}" en ${groupName}`, - textForPost: ({ firstTag, groupName, person, postName }) => `${person} publicó "${postName}" en ${groupName}${firstTag ? ` #${firstTag}` : ''}` + textForPost: ({ firstTag, groupName, person, postName }) => `${person} publicó "${postName}" en ${groupName}${firstTag ? ` #${firstTag}` : ''}`, + textForVoteReset: ({ person, postName, groupName }) => `${person} cambió las opciones de propuesta: "${postName}" en ${groupName}. Esto ha reiniciado los votos` } \ No newline at end of file From 341e25f1ff1e70f3177fc94a533894f280a621e6 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 9 May 2024 20:58:48 -0700 Subject: [PATCH 10/18] Add updateProposalOutcome --- api/graphql/index.js | 3 +++ api/graphql/mutations/index.js | 1 + api/graphql/mutations/post.js | 11 +++++++++++ api/graphql/schema.graphql | 2 ++ api/models/Post.js | 6 +++++- 5 files changed, 22 insertions(+), 1 deletion(-) diff --git a/api/graphql/index.js b/api/graphql/index.js index 585936988..dfa8559ea 100644 --- a/api/graphql/index.js +++ b/api/graphql/index.js @@ -103,6 +103,7 @@ import { updateMembership, updatePost, updateProposalOptions, + updateProposalOutcome, updateStripeAccount, updateWidget, useInvitation, @@ -496,6 +497,8 @@ export function makeMutations (expressContext, userId, isAdmin, fetchOne) { updateProposalOptions: (root, { postId, options }) => updateProposalOptions({ userId, postId, options }), + updateProposalOutcome: (root, { postId, proposalOutcome }) => updateProposalOutcome({ userId, postId, proposalOutcome }), + updateComment: (root, args) => updateComment(userId, args), updateStripeAccount: (root, { accountId }) => updateStripeAccount(userId, accountId), diff --git a/api/graphql/mutations/index.js b/api/graphql/mutations/index.js index e41a25b1f..9d3142591 100644 --- a/api/graphql/mutations/index.js +++ b/api/graphql/mutations/index.js @@ -69,6 +69,7 @@ export { swapProposalVote, updatePost, updateProposalOptions, + updateProposalOutcome, deletePost, pinPost } from './post' diff --git a/api/graphql/mutations/post.js b/api/graphql/mutations/post.js index 91b86a1a6..9db11fc36 100644 --- a/api/graphql/mutations/post.js +++ b/api/graphql/mutations/post.js @@ -127,6 +127,17 @@ export async function swapProposalVote ({ userId, postId, removeOptionId, addOpt } } +export function updateProposalOutcome ({ userId, postId, proposalOutcome }) { + return Post.find(postId) + .then(post => { + if (post.get('user_id') !== userId) { + throw new GraphQLYogaError("You don't have permission to modify this post") + } + return post.updateProposalOutcome(proposalOutcome) + }) + .then(() => ({ success: true })) +} + export async function pinPost (userId, postId, groupId) { const group = await Group.find(groupId) return GroupMembership.hasModeratorRole(userId, group) diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index 07c7da732..dcd6bcd9c 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -2251,6 +2251,8 @@ type Mutation { updatePost(id: ID, data: PostInput): Post # update options for a proposal updateProposalOptions(postId: ID, options: [ProposalOptionInput]): [ProposalOption] + # update the outcome of a proposal + updateProposalOutcome(postId: ID, proposalOutcome: String): GenericResult # NOT USED RIGHT NOW updateStripeAccount(accountId: String): GenericResult # Update settings for a GroupWidget diff --git a/api/models/Post.js b/api/models/Post.js index fe8471318..b13daa0cf 100644 --- a/api/models/Post.js +++ b/api/models/Post.js @@ -585,6 +585,10 @@ module.exports = bookshelf.Model.extend(Object.assign({ }) }, + updateProposalOutcome: function (proposalOutcome) { + return Post.where({ id: this.id }).query().update({ proposal_outcome: proposalOutcome}) + }, + removeFromGroup: function (idOrSlug) { return PostMembership.find(this.id, idOrSlug) .then(membership => membership.destroy()) @@ -683,7 +687,7 @@ module.exports = bookshelf.Model.extend(Object.assign({ }, find: function (id, options) { - return Post.where({id, 'posts.active': true}).fetch(options) + return Post.where({ id, 'posts.active': true }).fetch(options) }, createdInTimeRange: function (collection, startTime, endTime) { From 13be6b7c88e1e48ba47b8f505b1fa08f10862846 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 9 May 2024 22:32:02 -0700 Subject: [PATCH 11/18] Remove unnecessary condtional --- api/models/post/setupPostAttrs.js | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/api/models/post/setupPostAttrs.js b/api/models/post/setupPostAttrs.js index 20955afb2..2cb99055c 100644 --- a/api/models/post/setupPostAttrs.js +++ b/api/models/post/setupPostAttrs.js @@ -28,20 +28,10 @@ export default function setupPostAttrs (userId, params, create = false) { 'type' )) - let proposalAttrs = {} let proposalStatus = params.startTime && new Date(Number(params.startTime)) < new Date() ? Post.Proposal_Status.VOTING : Post.Proposal_Status.DISCUSSION if (params.endTime && new Date(Number(params.endTime)) < new Date()) proposalStatus = Post.Proposal_Status.COMPLETED - if (create) { - // if the startTime of the post is set and its before the current time in that timezone, then set the proposal status to VOTING - proposalAttrs = { - proposal_outcome: Post.Proposal_Outcome.IN_PROGRESS, - proposal_strict: params.isStrictProposal, - proposal_status: params.startTime ? proposalStatus : Post.Proposal_Status.CASUAL - } - } else { - proposalAttrs = { - proposal_status: params.startTime ? proposalStatus : Post.Proposal_Status.CASUAL - } + const proposalAttrs = { + proposal_status: params.startTime ? proposalStatus : Post.Proposal_Status.CASUAL } return Promise.resolve({ ...attrs, ...proposalAttrs }) From c9f036af9b54e672b39306508a0624a541b70932 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 18 May 2024 19:58:12 -0700 Subject: [PATCH 12/18] Logging for debuging of staging --- api/graphql/mutations/post.js | 5 ++++- api/models/Post.js | 1 + api/models/post/createPost.js | 7 ++++++- api/models/post/setupPostAttrs.js | 3 ++- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/api/graphql/mutations/post.js b/api/graphql/mutations/post.js index 9db11fc36..ca7b33d57 100644 --- a/api/graphql/mutations/post.js +++ b/api/graphql/mutations/post.js @@ -83,13 +83,15 @@ export async function removeProposalVote ({ userId, postId, optionId }) { } export async function setProposalOptions ({ userId, postId, options }) { + console.log('entering setProposalOptions') if (!userId || !postId || !options) throw new GraphQLYogaError(`Missing required parameters: ${JSON.stringify({ userId, postId, options })}`) const authorized = await Post.isVisibleToUser(postId, userId) if (!authorized) throw new GraphQLYogaError("You don't have permission to modify this post") return Post.find(postId) .then(post => { if (post.get('proposal_status') !== Post.Proposal_Status.DISCUSSION) throw new GraphQLYogaError("Proposal options cannot be changed unless the proposal is in 'discussion'") - return post.setProposalOptions({ options }) + console.log('setting options') + return post.setProposalOptions({ options }) }) .catch((err) => { throw new GraphQLYogaError(`setting of options failed: ${err}`) }) .then(() => ({ success: true })) @@ -156,6 +158,7 @@ export async function pinPost (userId, postId, groupId) { // the legacy code expects -- this sort of thing can be removed/refactored once // hylo-redux is no longer in use function convertGraphqlPostData (data) { + console.log(data, 'convertGraphqlPostData') return Promise.resolve(Object.assign({ name: data.title, description: data.details, diff --git a/api/models/Post.js b/api/models/Post.js index b13daa0cf..7b8c2b688 100644 --- a/api/models/Post.js +++ b/api/models/Post.js @@ -711,6 +711,7 @@ module.exports = bookshelf.Model.extend(Object.assign({ }), create: function (attrs, opts) { + console.log('entering Post.create') return Post.forge(_.merge(Post.newPostAttrs(), attrs)) .save(null, _.pick(opts, 'transacting')) }, diff --git a/api/models/post/createPost.js b/api/models/post/createPost.js index 63eb5ca38..a7114726b 100644 --- a/api/models/post/createPost.js +++ b/api/models/post/createPost.js @@ -2,8 +2,10 @@ import { flatten, merge, pick, uniq } from 'lodash' import setupPostAttrs from './setupPostAttrs' import updateChildren from './updateChildren' import { groupRoom, pushToSockets } from '../../services/Websockets' +const { GraphQLYogaError } = require('@graphql-yoga/node') export default async function createPost (userId, params) { + console.log('entering createPost') if (params.isPublic) { // Don't allow creating a public post unless at least one of the post's groups has allow_in_public set to true const groups = await Group.query(q => q.whereIn('id', params.group_ids)).fetchAll() @@ -17,6 +19,7 @@ export default async function createPost (userId, params) { pick(params, 'group_ids', 'imageUrl', 'videoUrl', 'docs', 'topicNames', 'memberIds', 'eventInviteeIds', 'imageUrls', 'fileUrls', 'announcement', 'location', 'location_id', 'proposalOptions'), {children: params.requests, transacting} )))).then(function(inserts) { + console.log('exiting createPost') return inserts }).catch(function(error) { throw error @@ -25,6 +28,7 @@ export default async function createPost (userId, params) { } export function afterCreatingPost (post, opts) { + console.log('entering afterCreatingPost') const userId = post.get('user_id') const mentioned = RichText.getUserMentions(post.details()) const followerIds = uniq(mentioned.concat(userId)) @@ -38,7 +42,7 @@ export function afterCreatingPost (post, opts) { // Add creator to RSVPs post.get('type') === 'event' && - EventInvitation.create({userId, inviterId: userId, eventId: post.id, response: EventInvitation.RESPONSE.YES}, trxOpts), + EventInvitation.create({ userId, inviterId: userId, eventId: post.id, response: EventInvitation.RESPONSE.YES }, trxOpts), // Add media, if any // redux version @@ -88,6 +92,7 @@ export function afterCreatingPost (post, opts) { .then(() => Queue.classMethod('Post', 'createActivities', { postId: post.id })) .then(() => Queue.classMethod('Post', 'notifySlack', { postId: post.id })) .then(() => Queue.classMethod('Post', 'zapierTriggers', { postId: post.id })) + .catch((err) => { throw new GraphQLYogaError(`afterCreatingPost failed: ${err}`) }) } async function updateTagsAndGroups (post, trx) { diff --git a/api/models/post/setupPostAttrs.js b/api/models/post/setupPostAttrs.js index 2cb99055c..0dac66330 100644 --- a/api/models/post/setupPostAttrs.js +++ b/api/models/post/setupPostAttrs.js @@ -2,6 +2,7 @@ import { merge, pick } from 'lodash' import { getOr } from 'lodash/fp' export default function setupPostAttrs (userId, params, create = false) { + console.log('entering setupPostAttrs') const attrs = merge({ accept_contributions: params.acceptContributions, anonymous_voting: params.isAnonymousVote, @@ -33,6 +34,6 @@ export default function setupPostAttrs (userId, params, create = false) { const proposalAttrs = { proposal_status: params.startTime ? proposalStatus : Post.Proposal_Status.CASUAL } - + console.log('exiting setupPostAttrs') return Promise.resolve({ ...attrs, ...proposalAttrs }) } From 0f3ff638c0b9313ac8313c5e795b108bd71493ee Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 22 May 2024 02:39:03 -0700 Subject: [PATCH 13/18] revert deferrable constraint --- api/models/Post.js | 2 -- migrations/20240218134105_proposals.js | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/api/models/Post.js b/api/models/Post.js index 7b8c2b688..a41655258 100644 --- a/api/models/Post.js +++ b/api/models/Post.js @@ -164,12 +164,10 @@ module.exports = bookshelf.Model.extend(Object.assign({ }, proposalOptions: function () { - // return this.hasMany(ProposalOption) return this.get('type') === Post.Type.PROPOSAL ? this.hasMany(ProposalOption) : null }, proposalVotes: function () { - // return this.hasMany(ProposalVote) return this.get('type') === Post.Type.PROPOSAL ? this.hasMany(ProposalVote) : null }, diff --git a/migrations/20240218134105_proposals.js b/migrations/20240218134105_proposals.js index 37a0aef52..12f6f8470 100644 --- a/migrations/20240218134105_proposals.js +++ b/migrations/20240218134105_proposals.js @@ -29,7 +29,7 @@ exports.up = async function (knex, Promise) { table.timestamp('created_at') }) - // await knex.raw('alter table proposal_options alter constraint proposal_options_post_id_foreign deferrable initially deferred') + await knex.raw('alter table proposal_options alter constraint proposal_options_post_id_foreign deferrable initially deferred') await knex.raw('alter table proposal_votes alter constraint proposal_votes_post_id_foreign deferrable initially deferred') await knex.raw('alter table proposal_votes alter constraint proposal_votes_option_id_foreign deferrable initially deferred') await knex.raw('alter table proposal_votes alter constraint proposal_votes_user_id_foreign deferrable initially deferred') From 75ce2f070bd3ba7881412b3a75eb286e6ec9c38d Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 22 May 2024 03:28:42 -0700 Subject: [PATCH 14/18] Drop proposal options FK constraint --- migrations/20240218134105_proposals.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/20240218134105_proposals.js b/migrations/20240218134105_proposals.js index 12f6f8470..48764a3a0 100644 --- a/migrations/20240218134105_proposals.js +++ b/migrations/20240218134105_proposals.js @@ -29,7 +29,7 @@ exports.up = async function (knex, Promise) { table.timestamp('created_at') }) - await knex.raw('alter table proposal_options alter constraint proposal_options_post_id_foreign deferrable initially deferred') + await knex.raw('alter table proposal_options DROP CONSTRAINT proposal_options_post_id_foreign deferrable initially deferred') await knex.raw('alter table proposal_votes alter constraint proposal_votes_post_id_foreign deferrable initially deferred') await knex.raw('alter table proposal_votes alter constraint proposal_votes_option_id_foreign deferrable initially deferred') await knex.raw('alter table proposal_votes alter constraint proposal_votes_user_id_foreign deferrable initially deferred') From 0dda17fa006d1cc273bcc0cf023ee502d09e4d31 Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 22 May 2024 03:33:19 -0700 Subject: [PATCH 15/18] Corrected migration --- migrations/20240218134105_proposals.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/20240218134105_proposals.js b/migrations/20240218134105_proposals.js index 48764a3a0..b2b5d0a5a 100644 --- a/migrations/20240218134105_proposals.js +++ b/migrations/20240218134105_proposals.js @@ -29,7 +29,7 @@ exports.up = async function (knex, Promise) { table.timestamp('created_at') }) - await knex.raw('alter table proposal_options DROP CONSTRAINT proposal_options_post_id_foreign deferrable initially deferred') + await knex.raw('alter table proposal_options DROP CONSTRAINT proposal_options_post_id_foreign') await knex.raw('alter table proposal_votes alter constraint proposal_votes_post_id_foreign deferrable initially deferred') await knex.raw('alter table proposal_votes alter constraint proposal_votes_option_id_foreign deferrable initially deferred') await knex.raw('alter table proposal_votes alter constraint proposal_votes_user_id_foreign deferrable initially deferred') From 1bedf637a0e565e29a1e57b896dd71d27c229be8 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 6 Jun 2024 12:20:46 -0700 Subject: [PATCH 16/18] Fix anon voting --- api/graphql/makeModels.js | 1 + 1 file changed, 1 insertion(+) diff --git a/api/graphql/makeModels.js b/api/graphql/makeModels.js index cb3f06a38..129be5052 100644 --- a/api/graphql/makeModels.js +++ b/api/graphql/makeModels.js @@ -212,6 +212,7 @@ export default function makeModels (userId, isAdmin, apiClient) { commenters: (p, { first }) => p.getCommenters(first, userId), commentersTotal: p => p.getCommentersTotal(userId), details: p => p.details(userId), + isAnonymousVote: p => p.get('anonymous_voting') === 'true', myReactions: p => userId ? p.reactionsForUser(userId).fetch() : [], myEventResponse: p => userId && p.isEvent() From 85a3e5b9adf3e812ff0699e2f2dbe319981aa158 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 10 Jun 2024 16:08:46 -0700 Subject: [PATCH 17/18] Flip proposal type to voting method --- api/graphql/makeModels.js | 2 +- api/graphql/schema.graphql | 4 ++-- api/models/Post.js | 4 ++-- api/models/post/setupPostAttrs.js | 2 +- .../20240610131324_proposalType-to-votingMethod.js | 12 ++++++++++++ migrations/schema.sql | 2 +- 6 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 migrations/20240610131324_proposalType-to-votingMethod.js diff --git a/api/graphql/makeModels.js b/api/graphql/makeModels.js index 129be5052..9110ac1e2 100644 --- a/api/graphql/makeModels.js +++ b/api/graphql/makeModels.js @@ -199,7 +199,7 @@ export default function makeModels (userId, isAdmin, apiClient) { 'location', 'project_management_link', 'proposal_status', - 'proposal_type', + 'voting_method', 'proposal_outcome', 'quorum', 'reactions_summary', diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index dcd6bcd9c..5c7f061a2 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -1670,7 +1670,7 @@ type Post { # Link to a project management tool or service. Only used for Projects right now projectManagementLink: String # The type of proposal: 'single', 'multiple-unrestricted', 'majority', 'consensus' - proposalType: String + votingMethod: String # Percentage of all group members that must vote for a proposal to complete quorum: Int # Number of total reactions on the post @@ -2748,7 +2748,7 @@ input PostInput { # The options for this proposal proposalOptions: [ProposalOptionInput] # The type of proposal: 'poll', 'survey', 'vote', or 'proposal' - proposalType: String + votingMethod: String # Percentage of all group members that must vote for a proposal to complete quorum: Int # When this post "starts". Used for events, resources, projects, requests and offers right now diff --git a/api/models/Post.js b/api/models/Post.js index a41655258..36b811506 100644 --- a/api/models/Post.js +++ b/api/models/Post.js @@ -255,7 +255,7 @@ module.exports = bookshelf.Model.extend(Object.assign({ return Object.assign({}, refineOne( this, - ['created_at', 'description', 'id', 'name', 'num_people_reacts', 'timezone', 'type', 'updated_at', 'num_votes', 'proposalType', 'proposalStatus', 'proposalOutcome'], + ['created_at', 'description', 'id', 'name', 'num_people_reacts', 'timezone', 'type', 'updated_at', 'num_votes', 'votingMethod', 'proposalStatus', 'proposalOutcome'], { description: 'details', name: 'title', num_people_reacts: 'peopleReactedTotal', num_votes: 'votesTotal' } ), { @@ -623,7 +623,7 @@ module.exports = bookshelf.Model.extend(Object.assign({ TIE: 'tie' }, - Proposal_Type: { + voting_method: { SINGLE: 'single', MULTI_UNRESTRICTED: 'multi-unrestricted' // unused for now diff --git a/api/models/post/setupPostAttrs.js b/api/models/post/setupPostAttrs.js index 0dac66330..70cf14d40 100644 --- a/api/models/post/setupPostAttrs.js +++ b/api/models/post/setupPostAttrs.js @@ -13,7 +13,7 @@ export default function setupPostAttrs (userId, params, create = false) { link_preview_id: params.link_preview_id || getOr(null, 'id', params.linkPreview), parent_post_id: params.parent_post_id, project_management_link: params.projectManagementLink, - proposal_type: params.proposalType, + voting_method: params.votingMethod, start_time: params.startTime ? new Date(Number(params.startTime)) : null, updated_at: new Date(), user_id: userId diff --git a/migrations/20240610131324_proposalType-to-votingMethod.js b/migrations/20240610131324_proposalType-to-votingMethod.js new file mode 100644 index 000000000..ba6c07b2f --- /dev/null +++ b/migrations/20240610131324_proposalType-to-votingMethod.js @@ -0,0 +1,12 @@ + +exports.up = async function(knex) { + await knex.schema.table('posts', function (table) { + table.renameColumn('proposal_type', 'voting_method') + }) +} + +exports.down = async function(knex) { + await knex.schema.table('posts', function (table) { + table.renameColumn('voting_method', 'proposal_type') + }) +} diff --git a/migrations/schema.sql b/migrations/schema.sql index b157a3202..1a7172b1c 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -1817,7 +1817,7 @@ CREATE TABLE public.posts ( proposal_status text, proposal_strict boolean DEFAULT false, proposal_outcome text, - proposal_type text, + voting_method text, anonymous_voting text, proposal_vote_limit integer ); From cccf78657ab9aed0ec8eed31ef3f4ad5942d5a8c Mon Sep 17 00:00:00 2001 From: Tibet Sprague Date: Wed, 3 Jul 2024 13:27:33 -0700 Subject: [PATCH 18/18] Update to version 5.8.0 --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 130f71023..8dfdbe7ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## [5.8.0] - 2024-07-03 + +### Added +- New post type: Proposals! Use these to propose ideas, projects, decisions, or anything else to your group. Proposals can be voted on, and have a status that can be updated by the proposer. + +### Fixed +- Deleting user accounts + ## [5.7.1] - 2024-02-20 ## Added diff --git a/package.json b/package.json index a00981177..af38ad398 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "author": "Hylo ", "license": "Apache-2.0", "private": true, - "version": "5.7.1", + "version": "5.8.0", "repository": { "type": "git", "url": "git://github.com/Hylozoic/hylo-node.git"