Skip to content

Commit

Permalink
feat: add comments to updates (#1161)
Browse files Browse the repository at this point in the history
* feat: add comments to updates

* refactor: components

* fix: use of forumUrl function

* refactor: add coauthor service

* feat: add update data to forum topic

* refactor: create vote service

* feat: update discourse with comment on update action

* feat: update status text on discourse message

* fix: issue with null topic data

* remove console.log

* refactor: DRY in comments component

* feat: update text
  • Loading branch information
andyesp authored Aug 9, 2023
1 parent d2f9099 commit 9cf55eb
Show file tree
Hide file tree
Showing 36 changed files with 629 additions and 292 deletions.
38 changes: 4 additions & 34 deletions src/back/routes/proposal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { Request } from 'express'
import isEthereumAddress from 'validator/lib/isEthereumAddress'
import isUUID from 'validator/lib/isUUID'

import { Discourse, DiscourseComment } from '../../clients/Discourse'
import { SnapshotGraphql } from '../../clients/SnapshotGraphql'
import { getVestingContractData } from '../../clients/VestingData'
import { BidRequest, BidRequestSchema } from '../../entities/Bid/types'
Expand All @@ -24,7 +23,6 @@ import {
SUBMISSION_THRESHOLD_TENDER,
} from '../../entities/Proposal/constants'
import ProposalModel from '../../entities/Proposal/model'
import { getUpdateMessage } from '../../entities/Proposal/templates/messages'
import {
CatalystType,
CategorizedGrants,
Expand Down Expand Up @@ -77,9 +75,6 @@ import {
} from '../../entities/Proposal/utils'
import { SNAPSHOT_DURATION } from '../../entities/Snapshot/constants'
import UpdateModel from '../../entities/Updates/model'
import UserModel from '../../entities/User/model'
import { filterComments } from '../../entities/User/utils'
import { inBackground } from '../../helpers'
import BidService from '../../services/BidService'
import { DiscourseService } from '../../services/DiscourseService'
import { ErrorService } from '../../services/ErrorService'
Expand All @@ -90,8 +85,6 @@ import Time from '../../utils/date/Time'
import { ErrorCategory } from '../../utils/errorCategories'
import { validateAddress } from '../utils/validations'

import { getVotes } from './votes'

export default routes((route) => {
const withAuth = auth()
const withOptionalAuth = auth({ optional: true })
Expand All @@ -113,7 +106,7 @@ export default routes((route) => {
route.get('/proposals/:proposal', handleAPI(getProposal))
route.patch('/proposals/:proposal', withAuth, handleAPI(updateProposalStatus))
route.delete('/proposals/:proposal', withAuth, handleAPI(removeProposal))
route.get('/proposals/:proposal/comments', handleAPI(proposalComments))
route.get('/proposals/:proposal/comments', handleAPI(getProposalComments))
route.get('/proposals/linked-wearables/image', handleAPI(checkImage))
})

Expand Down Expand Up @@ -505,24 +498,6 @@ export async function getProposal(req: Request<{ proposal: string }>) {

const updateProposalStatusValidator = schema.compile(updateProposalStatusScheme)

export function commentProposalUpdateInDiscourse(id: string) {
inBackground(async () => {
const updatedProposal: ProposalAttributes | undefined = await ProposalModel.findOne<ProposalAttributes>({ id })
if (!updatedProposal) {
logger.error('Invalid proposal id for discourse update', { id: id })
return
}
const votes = await getVotes(updatedProposal.id)
const updateMessage = getUpdateMessage(updatedProposal, votes)
const discourseComment: DiscourseComment = {
topic_id: updatedProposal.discourse_topic_id,
raw: updateMessage,
created_at: new Date().toJSON(),
}
await Discourse.get().commentOnPost(discourseComment)
})
}

export async function updateProposalStatus(req: WithAuth<Request<{ proposal: string }>>) {
const user = req.auth!
const id = req.params.proposal
Expand Down Expand Up @@ -571,7 +546,7 @@ export async function updateProposalStatus(req: WithAuth<Request<{ proposal: str

await ProposalModel.update<ProposalAttributes>(update, { id })

commentProposalUpdateInDiscourse(id)
ProposalService.commentProposalUpdateInDiscourse(id)

return {
...proposal,
Expand All @@ -588,15 +563,10 @@ export async function removeProposal(req: WithAuth<Request<{ proposal: string }>
return await ProposalService.removeProposal(proposal, user, updated_at, id)
}

export async function proposalComments(req: Request<{ proposal: string }>): Promise<ProposalCommentsInDiscourse> {
export async function getProposalComments(req: Request<{ proposal: string }>): Promise<ProposalCommentsInDiscourse> {
const proposal = await getProposal(req)
try {
const allComments = await DiscourseService.fetchAllComments(proposal.discourse_topic_id)
const userIds = new Set(allComments.map((comment) => comment.user_id))
const users = await UserModel.getAddressesByForumId(Array.from(userIds))
const filteredComments = filterComments(allComments, users)

return filteredComments
return await DiscourseService.getPostComments(proposal.discourse_topic_id)
} catch (error) {
logger.log('Error fetching discourse topic', {
error,
Expand Down
70 changes: 52 additions & 18 deletions src/back/routes/updates.ts → src/back/routes/update.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { WithAuth, auth } from 'decentraland-gatsby/dist/entities/Auth/middleware'
import logger from 'decentraland-gatsby/dist/entities/Development/logger'
import RequestError from 'decentraland-gatsby/dist/entities/Route/error'
import handleAPI from 'decentraland-gatsby/dist/entities/Route/handle'
import routes from 'decentraland-gatsby/dist/entities/Route/routes'
import { Request } from 'express'

import CoauthorModel from '../../entities/Coauthor/model'
import { CoauthorStatus } from '../../entities/Coauthor/types'
import ProposalModel from '../../entities/Proposal/model'
import { ProposalAttributes } from '../../entities/Proposal/types'
import UpdateModel from '../../entities/Updates/model'
Expand All @@ -18,18 +17,17 @@ import {
isBetweenLateThresholdDate,
} from '../../entities/Updates/utils'
import { DiscordService } from '../../services/DiscordService'
import { DiscourseService } from '../../services/DiscourseService'
import Time from '../../utils/date/Time'

// TODO: Move to backend-only Coauthors utils or service
const isCoauthor = async (proposalId: string, address: string): Promise<boolean> => {
const coauthors = await CoauthorModel.findCoauthors(proposalId, CoauthorStatus.APPROVED)
return !!coauthors.find((coauthor) => coauthor.address.toLowerCase() === address.toLowerCase())
}
import { ErrorCategory } from '../../utils/errorCategories'
import { CoauthorService } from '../services/coauthor'
import { UpdateService } from '../services/update'

export default routes((route) => {
const withAuth = auth()
route.get('/proposals/:proposal/updates', handleAPI(getProposalUpdates))
route.get('/proposals/:update/update', handleAPI(getProposalUpdate))
route.get('/proposals/:update_id/update/comments', handleAPI(getProposalUpdateComments))
route.post('/proposals/:proposal/update', withAuth, handleAPI(createProposalUpdate))
route.patch('/proposals/:proposal/update', withAuth, handleAPI(updateProposalUpdate))
route.delete('/proposals/:proposal/update', withAuth, handleAPI(deleteProposalUpdate))
Expand Down Expand Up @@ -58,7 +56,7 @@ async function getProposalUpdates(req: Request<{ proposal: string }>) {
throw new RequestError(`Proposal not found: "${proposal_id}"`, RequestError.NotFound)
}

const updates = await UpdateModel.find<UpdateAttributes>({ proposal_id })
const updates = await UpdateService.getAllByProposalId(proposal_id)
const publicUpdates = getPublicUpdates(updates)
const nextUpdate = getNextPendingUpdate(updates)
const currentUpdate = getCurrentUpdate(updates)
Expand All @@ -72,14 +70,41 @@ async function getProposalUpdates(req: Request<{ proposal: string }>) {
}
}

async function getProposalUpdateComments(req: Request<{ update_id: string }>) {
const update = await UpdateService.getById(req.params.update_id)
if (!update) {
throw new RequestError('Update not found', RequestError.NotFound)
}

const { id, discourse_topic_id } = update
if (!discourse_topic_id) {
throw new RequestError('No Discourse topic for this update', RequestError.NotFound)
}

try {
return await DiscourseService.getPostComments(discourse_topic_id)
} catch (error) {
logger.log('Error fetching discourse topic', {
error,
discourseTopicId: discourse_topic_id,
updateId: id,
category: ErrorCategory.Discourse,
})
return {
comments: [],
totalComments: 0,
}
}
}

async function createProposalUpdate(req: WithAuth<Request<{ proposal: string }>>) {
const { author, health, introduction, highlights, blockers, next_steps, additional_notes } = req.body

const user = req.auth!
const proposalId = req.params.proposal
const proposal = await ProposalModel.findOne<ProposalAttributes>({ id: proposalId })
const isAuthorOrCoauthor =
user && (proposal?.user === user || (await isCoauthor(proposalId, user))) && author === user
user && (proposal?.user === user || (await CoauthorService.isCoauthor(proposalId, user))) && author === user

if (!proposal || !isAuthorOrCoauthor) {
throw new RequestError(`Unauthorized`, RequestError.Forbidden)
Expand All @@ -97,7 +122,7 @@ async function createProposalUpdate(req: WithAuth<Request<{ proposal: string }>>
throw new RequestError(`Updates pending for this proposal`, RequestError.BadRequest)
}

const update = await UpdateModel.createUpdate({
const data = {
proposal_id: proposal.id,
author,
health,
Expand All @@ -106,16 +131,17 @@ async function createProposalUpdate(req: WithAuth<Request<{ proposal: string }>>
blockers,
next_steps,
additional_notes,
})

}
const update = await UpdateModel.createUpdate(data)
await DiscourseService.createUpdate(update, proposal.title)
DiscordService.newUpdate(proposal.id, proposal.title, update.id, user)

return update
}

async function updateProposalUpdate(req: WithAuth<Request<{ proposal: string }>>) {
const { id, author, health, introduction, highlights, blockers, next_steps, additional_notes } = req.body
const update = await UpdateModel.findOne(id)
const update = await UpdateModel.findOne<UpdateAttributes>(id)
const proposalId = req.params.proposal

if (!update) {
Expand All @@ -128,7 +154,7 @@ async function updateProposalUpdate(req: WithAuth<Request<{ proposal: string }>>
const proposal = await ProposalModel.findOne<ProposalAttributes>({ id: req.params.proposal })

const isAuthorOrCoauthor =
user && (proposal?.user === user || (await isCoauthor(proposalId, user))) && author === user
user && (proposal?.user === user || (await CoauthorService.isCoauthor(proposalId, user))) && author === user

if (!proposal || !isAuthorOrCoauthor) {
throw new RequestError(`Unauthorized`, RequestError.Forbidden)
Expand Down Expand Up @@ -159,8 +185,14 @@ async function updateProposalUpdate(req: WithAuth<Request<{ proposal: string }>>
{ id }
)

if (!completion_date) {
DiscordService.newUpdate(proposal.id, proposal.title, update.id, user)
const updatedUpdate = await UpdateService.getById(id)
if (updatedUpdate) {
if (!completion_date) {
DiscourseService.createUpdate(updatedUpdate, proposal.title)
DiscordService.newUpdate(proposal.id, proposal.title, update.id, user)
} else {
UpdateService.commentUpdateEditInDiscourse(updatedUpdate)
}
}

return true
Expand All @@ -182,7 +214,7 @@ async function deleteProposalUpdate(req: WithAuth<Request<{ proposal: string }>>
const user = req.auth
const proposal = await ProposalModel.findOne<ProposalAttributes>({ id: req.params.proposal })

const isAuthorOrCoauthor = user && (proposal?.user === user || (await isCoauthor(proposalId, user)))
const isAuthorOrCoauthor = user && (proposal?.user === user || (await CoauthorService.isCoauthor(proposalId, user)))

if (!proposal || !isAuthorOrCoauthor) {
throw new RequestError(`Unauthorized`, RequestError.Forbidden)
Expand All @@ -207,5 +239,7 @@ async function deleteProposalUpdate(req: WithAuth<Request<{ proposal: string }>>
)
}

UpdateService.commentUpdateDeleteInDiscourse(update)

return true
}
6 changes: 3 additions & 3 deletions src/back/routes/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,15 @@ async function checkValidationMessage(req: WithAuth) {

const { address, timestamp } = messageProperties

const comments = await DiscourseService.fetchAllComments(Number(GATSBY_DISCOURSE_CONNECT_THREAD))
const validationComment = getValidationComment(comments, address, timestamp)
const comments = await DiscourseService.getPostComments(Number(GATSBY_DISCOURSE_CONNECT_THREAD))
const validationComment = getValidationComment(comments.comments, address, timestamp)

if (validationComment) {
if (!isSameAddress(address, user) || !validateComment(validationComment, address, timestamp)) {
throw new Error('Validation failed')
}

await UserModel.createForumConnection(user, validationComment.user_id)
await UserModel.createForumConnection(user, validationComment.user_forum_id)
clearValidationInProgress(user)
}

Expand Down
6 changes: 1 addition & 5 deletions src/back/routes/votes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export async function getProposalVotes(req: Request<{ proposal: string }>) {
const refresh = req.query.refresh === 'true'

const proposal = await getProposal(req)
// TODO: Replace lines 29-32 with VoteService.getVotes
let latestVotes = await VotesModel.getVotes(proposal.id)
if (!latestVotes) {
latestVotes = await VotesModel.createEmpty(proposal.id)
Expand Down Expand Up @@ -74,11 +75,6 @@ export async function getCachedVotes(req: Request) {
}, {} as Record<string, Record<string, Vote>>)
}

export async function getVotes(proposal_id: string) {
const proposalVotes: VoteAttributes | null = await VotesModel.getVotes(proposal_id)
return proposalVotes?.votes ? proposalVotes.votes : await VotesModel.createEmpty(proposal_id)
}

async function getAddressVotesWithProposals(req: Request) {
const address = req.params.address
validateAddress(address)
Expand Down
10 changes: 10 additions & 0 deletions src/back/services/coauthor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import CoauthorModel from '../../entities/Coauthor/model'
import { CoauthorStatus } from '../../entities/Coauthor/types'
import { isSameAddress } from '../../entities/Snapshot/utils'

export class CoauthorService {
static async isCoauthor(proposalId: string, address: string): Promise<boolean> {
const coauthors = await CoauthorModel.findCoauthors(proposalId, CoauthorStatus.APPROVED)
return !!coauthors.find((coauthor) => isSameAddress(coauthor.address, address))
}
}
58 changes: 58 additions & 0 deletions src/back/services/update.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import logger from 'decentraland-gatsby/dist/entities/Development/logger'

import { Discourse, DiscoursePost } from '../../clients/Discourse'
import { ProposalAttributes } from '../../entities/Proposal/types'
import UpdateModel from '../../entities/Updates/model'
import { UpdateAttributes } from '../../entities/Updates/types'
import { getUpdateUrl } from '../../entities/Updates/utils'
import { inBackground } from '../../helpers'

export class UpdateService {
static async getById(id: UpdateAttributes['id']) {
return await UpdateModel.findOne<UpdateAttributes>({ id })
}

static async getAllByProposalId(proposal_id: ProposalAttributes['id']) {
return await UpdateModel.find<UpdateAttributes>({ proposal_id })
}

static async updateWithDiscoursePost(id: UpdateAttributes['id'], discoursePost: DiscoursePost) {
return await UpdateModel.update(
{ discourse_topic_id: discoursePost.topic_id, discourse_topic_slug: discoursePost.topic_slug },
{ id }
)
}

static commentUpdateEditInDiscourse(update: UpdateAttributes) {
inBackground(async () => {
if (!update.discourse_topic_id) {
logger.error('No discourse topic associated to this update', { id: update.id })
return
}

await Discourse.get().commentOnPost({
topic_id: update.discourse_topic_id,
raw: `This project update has been edited by the author. Please check the latest version on the [Governance dApp](${getUpdateUrl(
update.id,
update.proposal_id
)}).`,
created_at: new Date().toJSON(),
})
})
}

static commentUpdateDeleteInDiscourse(update: UpdateAttributes) {
inBackground(async () => {
if (!update.discourse_topic_id) {
logger.error('No discourse topic associated to this update', { id: update.id })
return
}

await Discourse.get().commentOnPost({
topic_id: update.discourse_topic_id,
raw: `This project update has been deleted by the author.`,
created_at: new Date().toJSON(),
})
})
}
}
8 changes: 8 additions & 0 deletions src/back/services/vote.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import VoteModel from '../../entities/Votes/model'

export class VoteService {
static async getVotes(proposal_id: string) {
const proposalVotes = await VoteModel.getVotes(proposal_id)
return proposalVotes?.votes ? proposalVotes.votes : await VoteModel.createEmpty(proposal_id)
}
}
5 changes: 5 additions & 0 deletions src/clients/Governance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,4 +549,9 @@ export class Governance extends API {
const response = await this.fetch<ApiResponse<VpDistribution>>(url, this.options().method('GET'))
return response.data
}

async getUpdateComments(update_id: string) {
const result = await this.fetch<ApiResponse<ProposalCommentsInDiscourse>>(`/proposals/${update_id}/update/comments`)
return result.data
}
}
Loading

0 comments on commit 9cf55eb

Please sign in to comment.