Skip to content

Commit

Permalink
chore: update project status on proposal enactment (#1828)
Browse files Browse the repository at this point in the history
* chore: create project links

* fix: add/delete link endpoints

* refactor: project editor validation

* refactor: remove unused method

* refactor: proposal status update

* chore: set project to in progress on proposal enactment

* refactor: disable logging finish proposal job run locally

* chore: start project on proposal enactment

* refactor: rename fn
  • Loading branch information
1emu committed Jun 5, 2024
1 parent fad9378 commit 959be0f
Show file tree
Hide file tree
Showing 9 changed files with 143 additions and 101 deletions.
88 changes: 12 additions & 76 deletions src/back/routes/proposal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,9 @@ import isEthereumAddress from 'validator/lib/isEthereumAddress'
import isUUID from 'validator/lib/isUUID'

import { SnapshotGraphql } from '../../clients/SnapshotGraphql'
import { getVestingContractData } from '../../clients/VestingData'
import { BidRequest, BidRequestSchema } from '../../entities/Bid/types'
import CoauthorModel from '../../entities/Coauthor/model'
import { CoauthorStatus } from '../../entities/Coauthor/types'
import isDAOCommittee from '../../entities/Committee/isDAOCommittee'
import { hasOpenSlots } from '../../entities/Committee/utils'
import { GrantRequest, getGrantRequestSchema, toGrantSubtype } from '../../entities/Grant/types'
import {
Expand Down Expand Up @@ -44,10 +42,10 @@ import {
ProposalAttributes,
ProposalCommentsInDiscourse,
ProposalRequiredVP,
ProposalStatus,
ProposalStatusUpdate,
ProposalStatusUpdateScheme,
ProposalType,
SortingOrder,
UpdateProposalStatusProposal,
newProposalBanNameScheme,
newProposalCatalystScheme,
newProposalDraftScheme,
Expand All @@ -58,7 +56,6 @@ import {
newProposalPitchScheme,
newProposalPollScheme,
newProposalTenderScheme,
updateProposalStatusScheme,
} from '../../entities/Proposal/types'
import {
DEFAULT_CHOICES,
Expand All @@ -71,18 +68,14 @@ import {
isAlreadyACatalyst,
isAlreadyBannedName,
isAlreadyPointOfInterest,
isProjectProposal,
isValidName,
isValidPointOfInterest,
isValidUpdateProposalStatus,
toProposalStatus,
toProposalType,
toSortingOrder,
} from '../../entities/Proposal/utils'
import { SNAPSHOT_DURATION } from '../../entities/Snapshot/constants'
import { isSameAddress } from '../../entities/Snapshot/utils'
import { validateUniqueAddresses } from '../../entities/Transparency/utils'
import UpdateModel from '../../entities/Updates/model'
import {
FinancialRecord,
FinancialUpdateSectionSchema,
Expand All @@ -109,9 +102,8 @@ import Time from '../../utils/date/Time'
import { ErrorCategory } from '../../utils/errorCategories'
import { isProdEnv } from '../../utils/governanceEnvs'
import logger from '../../utils/logger'
import { NotificationService } from '../services/notification'
import { UpdateService } from '../services/update'
import { validateAddress, validateId } from '../utils/validations'
import { validateAddress, validateId, validateIsDaoCommittee, validateStatusUpdate } from '../utils/validations'

export default routes((route) => {
const withAuth = auth()
Expand Down Expand Up @@ -542,76 +534,20 @@ export async function getProposalWithProject(req: Request<{ proposal: string }>)
}
}

const updateProposalStatusValidator = schema.compile(updateProposalStatusScheme)
const ProposalStatusUpdateValidator = schema.compile(ProposalStatusUpdateScheme)

export async function updateProposalStatus(req: WithAuth<Request<{ proposal: string }>>) {
const user = req.auth!
const id = req.params.proposal
if (!isDAOCommittee(user)) {
throw new RequestError('Only DAO committee members can enact a proposal', RequestError.Forbidden)
}

const proposal = await getProposal(req)
const configuration = validate<UpdateProposalStatusProposal>(updateProposalStatusValidator, req.body || {})
const newStatus = configuration.status
if (!isValidUpdateProposalStatus(proposal.status, newStatus)) {
throw new RequestError(
`${proposal.status} can't be updated to ${newStatus}`,
RequestError.BadRequest,
configuration
)
}
validateIsDaoCommittee(user)

const update: Partial<ProposalAttributes> = {
status: newStatus,
updated_at: new Date(),
}
const proposal = await getProposalWithProject(req)
const statusUpdate = validate<ProposalStatusUpdate>(ProposalStatusUpdateValidator, req.body || {})
validateStatusUpdate(proposal, statusUpdate)

const isProject = isProjectProposal(proposal.type)
const isEnactedStatus = update.status === ProposalStatus.Enacted
if (isEnactedStatus) {
update.enacted = true
update.enacted_by = user
if (isProject) {
const { vesting_addresses } = configuration
if (!vesting_addresses || vesting_addresses.length === 0) {
throw new RequestError('Vesting addresses are required for grant or bid proposals', RequestError.BadRequest)
}
if (vesting_addresses.some((address) => !isEthereumAddress(address))) {
throw new RequestError('Some vesting address is invalid', RequestError.BadRequest)
}
if (!validateUniqueAddresses(vesting_addresses)) {
throw new RequestError('Vesting addresses must be unique', RequestError.BadRequest)
}
update.vesting_addresses = vesting_addresses
update.textsearch = ProposalModel.textsearch(
proposal.title,
proposal.description,
proposal.user,
update.vesting_addresses
)
const vestingContractData = await getVestingContractData(vesting_addresses[vesting_addresses.length - 1], id)
await UpdateModel.createPendingUpdates(id, vestingContractData)
}
} else if (update.status === ProposalStatus.Passed) {
update.passed_by = user
} else if (update.status === ProposalStatus.Rejected) {
update.rejected_by = user
}

await ProposalModel.update<ProposalAttributes>(update, { id })
if (isEnactedStatus && isProject) {
NotificationService.projectProposalEnacted(proposal)
}

const updatedProposal = await ProposalModel.findOne<ProposalAttributes>({
id,
})
updatedProposal && DiscourseService.commentUpdatedProposal(updatedProposal)

return {
...proposal,
...update,
try {
return await ProposalService.updateProposalStatus(proposal, statusUpdate, user)
} catch (error: any) {
throw new RequestError(`Unable to update proposal: ${error.message}`, RequestError.Forbidden)
}
}

Expand Down
28 changes: 28 additions & 0 deletions src/back/utils/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import isUUID from 'validator/lib/isUUID'

import { SnapshotProposal } from '../../clients/SnapshotTypes'
import { ALCHEMY_DELEGATIONS_WEBHOOK_SECRET, DISCOURSE_WEBHOOK_SECRET } from '../../constants'
import isDAOCommittee from '../../entities/Committee/isDAOCommittee'
import isDebugAddress from '../../entities/Debug/isDebugAddress'
import { ProposalAttributes, ProposalStatus, ProposalStatusUpdate } from '../../entities/Proposal/types'
import { isProjectProposal, isValidProposalStatusUpdate } from '../../entities/Proposal/utils'
import { validateUniqueAddresses } from '../../entities/Transparency/utils'
import { ErrorService } from '../../services/ErrorService'
import { EventFilterSchema } from '../../shared/types/events'
import { ErrorCategory } from '../../utils/errorCategories'
Expand Down Expand Up @@ -154,3 +158,27 @@ export function validateEventTypesFilters(req: Request) {

return parsedEventTypes.data
}

export function validateIsDaoCommittee(user: string) {
if (!isDAOCommittee(user)) {
throw new RequestError('Only DAO committee members can update a proposal status', RequestError.Forbidden)
}
}

export function validateStatusUpdate(proposal: ProposalAttributes, statusUpdate: ProposalStatusUpdate) {
const { status: newStatus, vesting_addresses } = statusUpdate
if (!isValidProposalStatusUpdate(proposal.status, newStatus)) {
throw new RequestError(`${proposal.status} can't be updated to ${newStatus}`, RequestError.BadRequest, statusUpdate)
}
if (newStatus === ProposalStatus.Enacted && isProjectProposal(proposal.type)) {
if (!vesting_addresses || vesting_addresses.length === 0) {
throw new RequestError('Vesting addresses are required for grant or bid proposals', RequestError.BadRequest)
}
if (vesting_addresses.some((address) => !isEthereumAddress(address))) {
throw new RequestError('Some vesting address is invalid', RequestError.BadRequest)
}
if (!validateUniqueAddresses(vesting_addresses)) {
throw new RequestError('Vesting addresses must be unique', RequestError.BadRequest)
}
}
}
4 changes: 3 additions & 1 deletion src/entities/Proposal/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,9 @@ function logFinishableProposals(finishableProposals: ProposalAttributes[]) {
}

export async function finishProposal() {
logger.log(`Running finish proposal job...`)
if (isProdEnv()) {
logger.log(`Running finish proposal job...`)
}

try {
const finishableProposals = await ProposalModel.getFinishableProposals()
Expand Down
2 changes: 1 addition & 1 deletion src/entities/Proposal/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,7 @@ export default class ProposalModel extends Model<ProposalAttributes> {
}
}

static textsearch(title: string, description: string, user: string, vesting_addresses: string[]) {
static generateTextSearchVector(title: string, description: string, user: string, vesting_addresses: string[]) {
const addressExpressions = vesting_addresses.map((address) => SQL`setweight(to_tsvector(${address}), 'B')`)

return SQL`(${join(
Expand Down
4 changes: 2 additions & 2 deletions src/entities/Proposal/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,12 +208,12 @@ function requiredVotingPower(value: string | undefined | null, defaultValue: num
return defaultValue
}

export type UpdateProposalStatusProposal = {
export type ProposalStatusUpdate = {
status: ProposalStatus.Rejected | ProposalStatus.Passed | ProposalStatus.Enacted
vesting_addresses?: string[]
}

export const updateProposalStatusScheme = {
export const ProposalStatusUpdateScheme = {
type: 'object',
additionalProperties: false,
required: ['status'],
Expand Down
34 changes: 17 additions & 17 deletions src/entities/Proposal/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
isProposalDeletable,
isProposalEnactable,
isProposalStatus,
isValidUpdateProposalStatus,
isValidProposalStatusUpdate,
proposalCanBePassedOrRejected,
toProposalStatus,
} from './utils'
Expand Down Expand Up @@ -38,30 +38,30 @@ describe('toProposalStatus', () => {

describe('isValidUpdateProposalStatus', () => {
it('returns true when current status is Finished and next status is Rejected, Passed, Enacted, or Out of Budget', () => {
expect(isValidUpdateProposalStatus(ProposalStatus.Finished, ProposalStatus.Rejected)).toBe(true)
expect(isValidUpdateProposalStatus(ProposalStatus.Finished, ProposalStatus.Passed)).toBe(true)
expect(isValidUpdateProposalStatus(ProposalStatus.Finished, ProposalStatus.Enacted)).toBe(true)
expect(isValidUpdateProposalStatus(ProposalStatus.Finished, ProposalStatus.OutOfBudget)).toBe(true)
expect(isValidProposalStatusUpdate(ProposalStatus.Finished, ProposalStatus.Rejected)).toBe(true)
expect(isValidProposalStatusUpdate(ProposalStatus.Finished, ProposalStatus.Passed)).toBe(true)
expect(isValidProposalStatusUpdate(ProposalStatus.Finished, ProposalStatus.Enacted)).toBe(true)
expect(isValidProposalStatusUpdate(ProposalStatus.Finished, ProposalStatus.OutOfBudget)).toBe(true)
})

it('can only update to Enacted from Passed, Enacted, or Finished', () => {
expect(isValidUpdateProposalStatus(ProposalStatus.Passed, ProposalStatus.Enacted)).toBe(true)
expect(isValidUpdateProposalStatus(ProposalStatus.Enacted, ProposalStatus.Enacted)).toBe(true)
expect(isValidUpdateProposalStatus(ProposalStatus.Finished, ProposalStatus.Enacted)).toBe(true)
expect(isValidProposalStatusUpdate(ProposalStatus.Passed, ProposalStatus.Enacted)).toBe(true)
expect(isValidProposalStatusUpdate(ProposalStatus.Enacted, ProposalStatus.Enacted)).toBe(true)
expect(isValidProposalStatusUpdate(ProposalStatus.Finished, ProposalStatus.Enacted)).toBe(true)

expect(isValidUpdateProposalStatus(ProposalStatus.Active, ProposalStatus.Enacted)).toBe(false)
expect(isValidUpdateProposalStatus(ProposalStatus.Rejected, ProposalStatus.Enacted)).toBe(false)
expect(isValidUpdateProposalStatus(ProposalStatus.OutOfBudget, ProposalStatus.Enacted)).toBe(false)
expect(isValidUpdateProposalStatus(ProposalStatus.Deleted, ProposalStatus.Enacted)).toBe(false)
expect(isValidProposalStatusUpdate(ProposalStatus.Active, ProposalStatus.Enacted)).toBe(false)
expect(isValidProposalStatusUpdate(ProposalStatus.Rejected, ProposalStatus.Enacted)).toBe(false)
expect(isValidProposalStatusUpdate(ProposalStatus.OutOfBudget, ProposalStatus.Enacted)).toBe(false)
expect(isValidProposalStatusUpdate(ProposalStatus.Deleted, ProposalStatus.Enacted)).toBe(false)
})

it('returns false for Pending, Active, Rejected, OutOfBudget and Deleted statuses', () => {
Object.values(ProposalStatus).forEach((status) => {
expect(isValidUpdateProposalStatus(ProposalStatus.Pending, status)).toBe(false)
expect(isValidUpdateProposalStatus(ProposalStatus.Active, status)).toBe(false)
expect(isValidUpdateProposalStatus(ProposalStatus.Rejected, status)).toBe(false)
expect(isValidUpdateProposalStatus(ProposalStatus.OutOfBudget, status)).toBe(false)
expect(isValidUpdateProposalStatus(ProposalStatus.Deleted, status)).toBe(false)
expect(isValidProposalStatusUpdate(ProposalStatus.Pending, status)).toBe(false)
expect(isValidProposalStatusUpdate(ProposalStatus.Active, status)).toBe(false)
expect(isValidProposalStatusUpdate(ProposalStatus.Rejected, status)).toBe(false)
expect(isValidProposalStatusUpdate(ProposalStatus.OutOfBudget, status)).toBe(false)
expect(isValidProposalStatusUpdate(ProposalStatus.Deleted, status)).toBe(false)
})
})
})
Expand Down
2 changes: 1 addition & 1 deletion src/entities/Proposal/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export function isAlreadyACatalyst(domain: string) {
return !!getCatalystServersFromCache('mainnet').find((server) => server.address === 'https://' + domain)
}

export function isValidUpdateProposalStatus(current: ProposalStatus, next: ProposalStatus) {
export function isValidProposalStatusUpdate(current: ProposalStatus, next: ProposalStatus) {
switch (current) {
case ProposalStatus.Finished:
return (
Expand Down
10 changes: 10 additions & 0 deletions src/services/ProjectService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
ProposalProjectWithUpdate,
ProposalStatus,
ProposalType,
ProposalWithProject,
} from '../entities/Proposal/types'
import { DEFAULT_CHOICES, asNumber, getProposalEndDate, isProjectProposal } from '../entities/Proposal/utils'
import UpdateModel from '../entities/Updates/model'
Expand Down Expand Up @@ -317,4 +318,13 @@ export class ProjectService {
static async isAuthorOrCoauthor(user: string, projectId: string) {
return await ProjectModel.isAuthorOrCoauthor(user, projectId)
}

static async startOrResumeProject(proposal: ProposalWithProject, updated_at: Date) {
if (!proposal.project_id) throw new Error(`Project not found for proposal: "${proposal.id}"`)
if (proposal.project_status === ProjectStatus.Pending || proposal.project_status === ProjectStatus.Paused) {
await ProjectModel.update({ status: ProjectStatus.InProgress, updated_at }, { id: proposal.project_id })
} else {
throw new Error(`Cannot update ${proposal.project_status} Project to In Progress for proposal: "${proposal.id}"`)
}
}
}
Loading

0 comments on commit 959be0f

Please sign in to comment.