diff --git a/package.json b/package.json index f122a6c48..49b64d639 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "build": "npm run build:server", "build:server": "tsc -p .", "format": "prettier --write \"**/*.{js,jsx,json,md,ts,tsx}\"", - "start": "concurrently -c blue,green -n SERVER,FRONT 'npm run serve' 'npm run develop'", + "start": "concurrently -c blue -n SERVER 'npm run serve'", "serve": "DOTENV_CONFIG_PATH=.env.development nodemon --watch src/entities --watch src/back --watch src/clients --watch src/services --watch src/server.ts --watch static/api.yaml -e ts,json --exec 'ts-node -r dotenv/config' src/server", "version": "tsc -p .", "migrate": "DOTENV_CONFIG_PATH=.env.development ts-node -r dotenv/config.js ./node_modules/node-pg-migrate/bin/node-pg-migrate -j ts -m src/migrations -d CONNECTION_STRING", diff --git a/src/back/jobs/ProjectsMigration.ts b/src/back/jobs/ProjectsMigration.ts new file mode 100644 index 000000000..58f5f353a --- /dev/null +++ b/src/back/jobs/ProjectsMigration.ts @@ -0,0 +1,10 @@ +import { ProjectService } from '../../services/ProjectService' + +export async function migrateProjects() { + const result = await ProjectService.migrateProjects() + if (result.error || result.migrationErrors.length > 0) { + throw JSON.stringify(result) + } + + return result +} diff --git a/src/back/models/Personnel.ts b/src/back/models/Personnel.ts new file mode 100644 index 000000000..872e5e446 --- /dev/null +++ b/src/back/models/Personnel.ts @@ -0,0 +1,18 @@ +import { Model } from 'decentraland-gatsby/dist/entities/Database/model' + +import { TeamMember } from '../../entities/Grant/types' + +export type PersonnelAttributes = TeamMember & { + id: string + project_id: string + deleted: boolean + updated_by?: string + updated_at?: Date + created_at: Date +} + +export default class PersonnelModel extends Model { + static tableName = 'personnel' + static withTimestamps = false + static primaryKey = 'id' +} diff --git a/src/back/models/Project.ts b/src/back/models/Project.ts new file mode 100644 index 000000000..7de5bea63 --- /dev/null +++ b/src/back/models/Project.ts @@ -0,0 +1,115 @@ +import crypto from 'crypto' +import { Model } from 'decentraland-gatsby/dist/entities/Database/model' +import { SQL, join, table } from 'decentraland-gatsby/dist/entities/Database/utils' +import isEthereumAddress from 'validator/lib/isEthereumAddress' +import isUUID from 'validator/lib/isUUID' + +import CoauthorModel from '../../entities/Coauthor/model' +import { CoauthorStatus } from '../../entities/Coauthor/types' +import { ProjectStatus } from '../../entities/Grant/types' +import ProposalModel from '../../entities/Proposal/model' +import { ProjectFunding, ProposalProject } from '../../entities/Proposal/types' + +import PersonnelModel, { PersonnelAttributes } from './Personnel' +import ProjectLinkModel, { ProjectLink } from './ProjectLink' +import ProjectMilestoneModel, { ProjectMilestone } from './ProjectMilestone' + +export type ProjectAttributes = { + id: string + proposal_id: string + title: string + status: ProjectStatus + about?: string + about_updated_by?: string + about_updated_at?: Date + updated_at?: Date + created_at: Date +} + +export type Project = ProjectAttributes & { + personnel: PersonnelAttributes[] + links: ProjectLink[] + milestones: ProjectMilestone[] + author: string + coauthors: string[] | null + vesting_addresses: string[] + funding?: ProjectFunding +} + +export default class ProjectModel extends Model { + static tableName = 'projects' + static withTimestamps = false + static primaryKey = 'id' + + static async getProject(id: string) { + if (!isUUID(id || '')) { + throw new Error(`Invalid project id: "${id}"`) + } + + const query = SQL` + SELECT + pr.*, + p.user as author, + p.vesting_addresses as vesting_addresses, + COALESCE(json_agg(DISTINCT to_jsonb(pe.*)) FILTER (WHERE pe.id IS NOT NULL), '[]') as personnel, + COALESCE(json_agg(DISTINCT to_jsonb(mi.*)) FILTER (WHERE mi.id IS NOT NULL), '[]') as milestones, + COALESCE(json_agg(DISTINCT to_jsonb(li.*)) FILTER (WHERE li.id IS NOT NULL), '[]') as links, + COALESCE(array_agg(co.address) FILTER (WHERE co.address IS NOT NULL), '{}') AS coauthors + FROM ${table(ProjectModel)} pr + JOIN ${table(ProposalModel)} p ON pr.proposal_id = p.id + LEFT JOIN ${table(PersonnelModel)} pe ON pr.id = pe.project_id AND pe.deleted = false + LEFT JOIN ${table(ProjectMilestoneModel)} mi ON pr.id = mi.project_id + LEFT JOIN ${table(ProjectLinkModel)} li ON pr.id = li.project_id + LEFT JOIN ${table(CoauthorModel)} co ON pr.proposal_id = co.proposal_id + AND co.status = ${CoauthorStatus.APPROVED} + WHERE pr.id = ${id} + GROUP BY pr.id, p.user, p.vesting_addresses; + ` + + const result = await this.namedQuery(`get_project`, query) + if (!result || result.length === 0) { + throw new Error(`Project not found: "${id}"`) + } + + return result[0] + } + + static async migrate(proposals: ProposalProject[]) { + const values = proposals.map( + ({ id, title, about, status, updated_at }) => + SQL`(${crypto.randomUUID()}, ${id}, ${title}, ${about}, ${status}, ${new Date(updated_at)})` + ) + + const query = SQL` + INSERT INTO ${table(ProjectModel)} (id, proposal_id, title, about, status, created_at) + VALUES ${join(values, SQL`, `)} + RETURNING *; + ` + + return this.namedQuery(`create_multiple_projects`, query) + } + + static async isAuthorOrCoauthor(user: string, projectId: string): Promise { + if (!isUUID(projectId || '')) { + throw new Error(`Invalid project id: "${projectId}"`) + } + if (!isEthereumAddress(user)) { + throw new Error(`Invalid user: "${user}"`) + } + + const query = SQL` + SELECT EXISTS ( + SELECT 1 + FROM ${table(ProjectModel)} pr + JOIN ${table(ProposalModel)} p ON pr.proposal_id = p.id + LEFT JOIN ${table(CoauthorModel)} co ON pr.proposal_id = co.proposal_id + AND co.status = ${CoauthorStatus.APPROVED} + WHERE pr.id = ${projectId} + AND (p.user = ${user} OR co.address = ${user}) + ) AS "exists" + ` + + const result = await this.namedQuery<{ exists: boolean }>(`is_author_or_coauthor`, query) + return result[0]?.exists || false + } +} diff --git a/src/back/models/ProjectLink.ts b/src/back/models/ProjectLink.ts new file mode 100644 index 000000000..8820635bd --- /dev/null +++ b/src/back/models/ProjectLink.ts @@ -0,0 +1,18 @@ +import { Model } from 'decentraland-gatsby/dist/entities/Database/model' + +export type ProjectLink = { + id: string + project_id: string + label: string + url: string + updated_by?: string + updated_at?: Date + created_by: string + created_at: Date +} + +export default class ProjectLinkModel extends Model { + static tableName = 'project_links' + static withTimestamps = false + static primaryKey = 'id' +} diff --git a/src/back/models/ProjectMilestone.ts b/src/back/models/ProjectMilestone.ts new file mode 100644 index 000000000..19daf90f0 --- /dev/null +++ b/src/back/models/ProjectMilestone.ts @@ -0,0 +1,26 @@ +import { Model } from 'decentraland-gatsby/dist/entities/Database/model' + +export type ProjectMilestone = { + id: string + project_id: string + title: string + description: string + delivery_date: Date + status: ProjectMilestoneStatus + updated_by?: string + updated_at?: Date + created_by: string + created_at: Date +} + +export enum ProjectMilestoneStatus { + Pending = 'pending', + InProgress = 'in_progress', + Done = 'done', +} + +export default class ProjectMilestoneModel extends Model { + static tableName = 'project_milestones' + static withTimestamps = false + static primaryKey = 'id' +} diff --git a/src/back/models/ProjectMilestoneUpdate.ts b/src/back/models/ProjectMilestoneUpdate.ts new file mode 100644 index 000000000..d5cccc1e7 --- /dev/null +++ b/src/back/models/ProjectMilestoneUpdate.ts @@ -0,0 +1,16 @@ +import { Model } from 'decentraland-gatsby/dist/entities/Database/model' + +export type ProjectMilestoneUpdate = { + id: string + update_id: string + milestone_id: string + description: string + tasks: string[] + created_at: Date +} + +export default class ProjectMilestoneUpdateModel extends Model { + static tableName = 'project_milestone_updates' + static withTimestamps = false + static primaryKey = 'id' +} diff --git a/src/back/routes/budget.ts b/src/back/routes/budget.ts index 4b4ef2ad8..ed95b18ae 100644 --- a/src/back/routes/budget.ts +++ b/src/back/routes/budget.ts @@ -8,7 +8,7 @@ import { Budget, BudgetWithContestants, CategoryBudget } from '../../entities/Bu import { QuarterBudgetAttributes } from '../../entities/QuarterBudget/types' import { toNewGrantCategory } from '../../entities/QuarterCategoryBudget/utils' import { BudgetService } from '../../services/BudgetService' -import { validateProposalId } from '../utils/validations' +import { validateId } from '../utils/validations' export default routes((route) => { const withAuth = auth() @@ -48,6 +48,6 @@ async function getCurrentContestedBudget(): Promise { } async function getBudgetWithContestants(req: Request<{ proposal: string }>): Promise { - const id = validateProposalId(req.params.proposal) + const id = validateId(req.params.proposal) return await BudgetService.getBudgetWithContestants(id) } diff --git a/src/back/routes/debug.ts b/src/back/routes/debug.ts index a4f3e3ea1..45bcc8b67 100644 --- a/src/back/routes/debug.ts +++ b/src/back/routes/debug.ts @@ -6,12 +6,14 @@ import { DEBUG_ADDRESSES } from '../../constants' import CacheService from '../../services/CacheService' import { ErrorService } from '../../services/ErrorService' import { giveAndRevokeLandOwnerBadges, giveTopVoterBadges, runQueuedAirdropJobs } from '../jobs/BadgeAirdrop' +import { migrateProjects } from '../jobs/ProjectsMigration' import { validateDebugAddress } from '../utils/validations' const FUNCTIONS_MAP: { [key: string]: () => Promise } = { runQueuedAirdropJobs, giveAndRevokeLandOwnerBadges, giveTopVoterBadges, + migrateProjects, } export default routes((router) => { diff --git a/src/back/routes/events.ts b/src/back/routes/events.ts index 3d4653be2..c1614327f 100644 --- a/src/back/routes/events.ts +++ b/src/back/routes/events.ts @@ -7,7 +7,7 @@ import { EventsService } from '../services/events' import { validateDebugAddress, validateEventTypesFilters, - validateProposalId, + validateId, validateRequiredString, } from '../utils/validations' @@ -26,7 +26,7 @@ async function getLatestEvents(req: Request) { async function voted(req: WithAuth) { const user = req.auth! - validateProposalId(req.body.proposalId) + validateId(req.body.proposalId) validateRequiredString('proposalTitle', req.body.proposalTitle) validateRequiredString('choice', req.body.choice) return await EventsService.voted(req.body.proposalId, req.body.proposalTitle, req.body.choice, user) diff --git a/src/back/routes/project.ts b/src/back/routes/project.ts index 82dedbc9b..6060c73fa 100644 --- a/src/back/routes/project.ts +++ b/src/back/routes/project.ts @@ -1,19 +1,36 @@ +import { WithAuth, auth } from 'decentraland-gatsby/dist/entities/Auth/middleware' import RequestError from 'decentraland-gatsby/dist/entities/Route/error' -import { handleJSON } from 'decentraland-gatsby/dist/entities/Route/handle' +import handleAPI, { handleJSON } from 'decentraland-gatsby/dist/entities/Route/handle' import routes from 'decentraland-gatsby/dist/entities/Route/routes' import { Request } from 'express' +import { + PersonnelInCreationSchema, + ProjectLinkInCreationSchema, + ProjectMilestoneInCreationSchema, +} from '../../entities/Project/types' import CacheService, { TTL_1_HS } from '../../services/CacheService' import { ProjectService } from '../../services/ProjectService' -import { isValidDate } from '../utils/validations' +import PersonnelModel, { PersonnelAttributes } from '../models/Personnel' +import ProjectLinkModel, { ProjectLink } from '../models/ProjectLink' +import ProjectMilestoneModel, { ProjectMilestone } from '../models/ProjectMilestone' +import { isValidDate, validateCanEditProject, validateId } from '../utils/validations' export default routes((route) => { + const withAuth = auth() route.get('/projects', handleJSON(getProjects)) + route.post('/projects/personnel/', withAuth, handleAPI(addPersonnel)) + route.delete('/projects/personnel/:personnel_id', withAuth, handleAPI(deletePersonnel)) + route.post('/projects/links/', withAuth, handleAPI(addLink)) + route.delete('/projects/links/:link_id', withAuth, handleAPI(deleteLink)) + route.post('/projects/milestones/', withAuth, handleAPI(addMilestone)) + route.delete('/projects/milestones/:milestone_id', withAuth, handleAPI(deleteMilestone)) + route.get('/projects/:project', handleAPI(getProject)) route.get('/projects/pitches-total', handleJSON(getOpenPitchesTotal)) route.get('/projects/tenders-total', handleJSON(getOpenTendersTotal)) }) -type ProjectsReturnType = Awaited> +type ProjectsReturnType = Awaited> function filterProjectsByDate(projects: ProjectsReturnType, from?: Date, to?: Date): ProjectsReturnType { return { @@ -37,11 +54,21 @@ async function getProjects(req: Request) { if (cachedProjects) { return filterProjectsByDate(cachedProjects, from, to) } - const projects = await ProjectService.getProjects() + const projects = await ProjectService.getProposalProjects() CacheService.set(cacheKey, projects, TTL_1_HS) return filterProjectsByDate(projects, from, to) } +async function getProject(req: Request<{ project: string }>) { + const id = validateId(req.params.project) + try { + return await ProjectService.getUpdatedProject(id) + } catch (e) { + console.log(`Error getting project: ${e}`) //TODO: remove before merging projects to main + throw new RequestError(`Project "${id}" not found`, RequestError.NotFound) + } +} + async function getOpenPitchesTotal() { return await ProjectService.getOpenPitchesTotal() } @@ -49,3 +76,82 @@ async function getOpenPitchesTotal() { async function getOpenTendersTotal() { return await ProjectService.getOpenTendersTotal() } + +async function addPersonnel(req: WithAuth): Promise { + const user = req.auth! + const { personnel } = req.body + const projectId = personnel.project_id + await validateCanEditProject(user, projectId) + const parsedPersonnel = PersonnelInCreationSchema.safeParse(personnel) + if (!parsedPersonnel.success) { + throw new RequestError(`Invalid personnel: ${parsedPersonnel.error.message}`, RequestError.BadRequest) + } + + return await ProjectService.addPersonnel(parsedPersonnel.data, user) +} + +async function deletePersonnel(req: WithAuth>): Promise { + const user = req.auth! + const personnel_id = req.params.personnel_id + validateId(personnel_id) + const personnel = await PersonnelModel.findOne(personnel_id) + if (!personnel) { + throw new RequestError(`Personnel "${personnel_id}" not found`, RequestError.NotFound) + } + await validateCanEditProject(user, personnel.project_id) + + return await ProjectService.deletePersonnel(personnel_id, user) +} + +async function addLink(req: WithAuth): Promise { + const user = req.auth! + const { project_link } = req.body + await validateCanEditProject(user, project_link.project_id) + const parsedLink = ProjectLinkInCreationSchema.safeParse(project_link) + if (!parsedLink.success) { + throw new RequestError(`Invalid link: ${parsedLink.error.message}`, RequestError.BadRequest) + } + + return await ProjectService.addLink(parsedLink.data, user) +} + +async function deleteLink(req: WithAuth>): Promise { + const user = req.auth! + const link_id = req.params.link_id + validateId(link_id) + const projectLink = await ProjectLinkModel.findOne(link_id) + if (!projectLink) { + throw new RequestError(`Link "${link_id}" not found`, RequestError.NotFound) + } + await validateCanEditProject(user, projectLink.project_id) + + return await ProjectService.deleteLink(link_id) +} +async function addMilestone(req: WithAuth): Promise { + const user = req.auth! + const { milestone } = req.body + await validateCanEditProject(user, milestone.project_id) + const formattedMilestone = { + ...milestone, + delivery_date: new Date(milestone.delivery_date), + } + const parsedMilestone = ProjectMilestoneInCreationSchema.safeParse(formattedMilestone) + if (!parsedMilestone.success) { + throw new RequestError(`Invalid milestone: ${parsedMilestone.error.message}`, RequestError.BadRequest) + } + + return await ProjectService.addMilestone(parsedMilestone.data, user) +} + +async function deleteMilestone(req: WithAuth>): Promise { + const user = req.auth! + const milestone_id = req.params.milestone_id + validateId(milestone_id) + const milestone = await ProjectMilestoneModel.findOne(milestone_id) + if (!milestone) { + throw new RequestError(`Milestone "${milestone_id}" not found`, RequestError.NotFound) + } + await validateCanEditProject(user, milestone.project_id) + + return await ProjectService.deleteMilestone(milestone_id) +} diff --git a/src/back/routes/proposal.ts b/src/back/routes/proposal.ts index 50cd32d1f..be5ce27ea 100644 --- a/src/back/routes/proposal.ts +++ b/src/back/routes/proposal.ts @@ -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 { @@ -44,10 +42,10 @@ import { ProposalAttributes, ProposalCommentsInDiscourse, ProposalRequiredVP, - ProposalStatus, + ProposalStatusUpdate, + ProposalStatusUpdateScheme, ProposalType, SortingOrder, - UpdateProposalStatusProposal, newProposalBanNameScheme, newProposalCatalystScheme, newProposalDraftScheme, @@ -58,7 +56,6 @@ import { newProposalPitchScheme, newProposalPollScheme, newProposalTenderScheme, - updateProposalStatusScheme, } from '../../entities/Proposal/types' import { DEFAULT_CHOICES, @@ -71,47 +68,25 @@ 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, - GeneralUpdateSectionSchema, - UpdateGeneralSection, -} from '../../entities/Updates/types' -import { - getCurrentUpdate, - getFundsReleasedSinceLatestUpdate, - getLatestUpdate, - getNextPendingUpdate, - getPendingUpdates, - getPublicUpdates, - getReleases, -} from '../../entities/Updates/utils' import BidService from '../../services/BidService' import { DiscourseService } from '../../services/DiscourseService' import { ErrorService } from '../../services/ErrorService' import { ProjectService } from '../../services/ProjectService' import { ProposalInCreation, ProposalService } from '../../services/ProposalService' -import { VestingService } from '../../services/VestingService' import { getProfile } from '../../utils/Catalyst' 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, validateProposalId } from '../utils/validations' +import { validateAddress, validateId, validateIsDaoCommittee, validateStatusUpdate } from '../utils/validations' export default routes((route) => { const withAuth = auth() @@ -131,12 +106,10 @@ export default routes((route) => { route.post('/proposals/hiring', withAuth, handleAPI(createProposalHiring)) route.get('/proposals/priority/:address?', handleJSON(getPriorityProposals)) route.get('/proposals/grants/:address', handleAPI(getGrantsByUser)) - route.get('/proposals/:proposal', handleAPI(getProposal)) + route.get('/proposals/:proposal', handleAPI(getProposalWithProject)) route.patch('/proposals/:proposal', withAuth, handleAPI(updateProposalStatus)) route.delete('/proposals/:proposal', withAuth, handleAPI(removeProposal)) route.get('/proposals/:proposal/comments', handleAPI(getProposalComments)) - route.get('/proposals/:proposal/updates', handleAPI(getProposalUpdates)) - route.post('/proposals/:proposal/update', withAuth, handleAPI(createProposalUpdate)) route.get('/proposals/linked-wearables/image', handleAPI(checkImage)) }) @@ -525,7 +498,7 @@ async function getPriorityProposals(req: Request) { } export async function getProposal(req: Request<{ proposal: string }>) { - const id = validateProposalId(req.params.proposal) + const id = validateId(req.params.proposal) try { return await ProposalService.getProposal(id) } catch (e) { @@ -533,76 +506,29 @@ export async function getProposal(req: Request<{ proposal: string }>) { } } -const updateProposalStatusValidator = schema.compile(updateProposalStatusScheme) - -export async function updateProposalStatus(req: WithAuth>) { - 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(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 - ) - } - - const update: Partial = { - status: newStatus, - updated_at: new Date(), +export async function getProposalWithProject(req: Request<{ proposal: string }>) { + const id = validateId(req.params.proposal) + try { + return await ProposalService.getProposalWithProject(id) + } catch (e) { + throw new RequestError(`Proposal "${id}" not found`, RequestError.NotFound) } +} - 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 - } +const ProposalStatusUpdateValidator = schema.compile(ProposalStatusUpdateScheme) - await ProposalModel.update(update, { id }) - if (isEnactedStatus && isProject) { - NotificationService.projectProposalEnacted(proposal) - } +export async function updateProposalStatus(req: WithAuth>) { + const user = req.auth! + validateIsDaoCommittee(user) - const updatedProposal = await ProposalModel.findOne({ - id, - }) - updatedProposal && DiscourseService.commentUpdatedProposal(updatedProposal) + const proposal = await getProposalWithProject(req) + const statusUpdate = validate(ProposalStatusUpdateValidator, req.body || {}) + validateStatusUpdate(proposal, statusUpdate) - 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) } } @@ -671,7 +597,7 @@ async function getGrantsByUser(req: Request) { const coauthoring = await CoauthorModel.findProposals(address, CoauthorStatus.APPROVED) const coauthoringProposalIds = new Set(coauthoring.map((coauthoringAttributes) => coauthoringAttributes.proposal_id)) - const projects = await ProjectService.getProjects() + const projects = await ProjectService.getProposalProjects() const filteredGrants = projects.data.filter( (project) => project.type === ProposalType.Grant && @@ -700,84 +626,3 @@ async function checkImage(req: Request) { }) }) } - -async function getProposalUpdates(req: Request<{ proposal: string }>) { - const proposal_id = req.params.proposal - - if (!proposal_id) { - throw new RequestError(`Proposal not found: "${proposal_id}"`, RequestError.NotFound) - } - - const updates = await UpdateService.getAllByProposalId(proposal_id) - const publicUpdates = getPublicUpdates(updates) - const nextUpdate = getNextPendingUpdate(updates) - const currentUpdate = getCurrentUpdate(updates) - const pendingUpdates = getPendingUpdates(updates) - - return { - publicUpdates, - pendingUpdates, - nextUpdate, - currentUpdate, - } -} - -function parseFinancialRecords(financial_records: unknown) { - const parsedResult = FinancialUpdateSectionSchema.safeParse({ financial_records }) - if (!parsedResult.success) { - ErrorService.report('Submission of invalid financial records', { - error: parsedResult.error, - category: ErrorCategory.Financial, - }) - throw new RequestError(`Invalid financial records`, RequestError.BadRequest) - } - return parsedResult.data.financial_records -} - -async function validateFinancialRecords( - proposal: ProposalAttributes, - financial_records: unknown -): Promise { - const [vestingData, updates] = await Promise.all([ - VestingService.getVestingInfo(proposal.vesting_addresses), - UpdateService.getAllByProposalId(proposal.id), - ]) - - const releases = vestingData ? getReleases(vestingData) : undefined - const publicUpdates = getPublicUpdates(updates) - const latestUpdate = getLatestUpdate(publicUpdates || []) - const { releasedFunds } = getFundsReleasedSinceLatestUpdate(latestUpdate, releases) - return releasedFunds > 0 ? parseFinancialRecords(financial_records) : null -} - -async function createProposalUpdate(req: WithAuth>) { - const { author, financial_records, ...body } = req.body - const { health, introduction, highlights, blockers, next_steps, additional_notes } = validate( - schema.compile(GeneralUpdateSectionSchema), - body - ) - try { - const proposal = await getProposal(req) - const financialRecords = await validateFinancialRecords(proposal, financial_records) - return await UpdateService.create( - { - proposal_id: req.params.proposal, - author, - health, - introduction, - highlights, - blockers, - next_steps, - additional_notes, - financial_records: financialRecords, - }, - req.auth! - ) - } catch (error) { - ErrorService.report('Error creating update', { - error, - category: ErrorCategory.Update, - }) - throw new RequestError(`Something went wrong: ${error}`, RequestError.InternalServerError) - } -} diff --git a/src/back/routes/update.ts b/src/back/routes/update.ts index 46509af7b..6b381cf7d 100644 --- a/src/back/routes/update.ts +++ b/src/back/routes/update.ts @@ -8,29 +8,40 @@ import { Request } from 'express' import isNaN from 'lodash/isNaN' import toNumber from 'lodash/toNumber' -import ProposalModel from '../../entities/Proposal/model' -import { ProposalAttributes } from '../../entities/Proposal/types' import { + FinancialRecord, FinancialUpdateSectionSchema, GeneralUpdateSectionSchema, UpdateGeneralSection, } from '../../entities/Updates/types' -import { isBetweenLateThresholdDate } from '../../entities/Updates/utils' +import { + getCurrentUpdate, + getFundsReleasedSinceLatestUpdate, + getLatestUpdate, + getNextPendingUpdate, + getPendingUpdates, + getPublicUpdates, + getReleases, +} from '../../entities/Updates/utils' import { DiscourseService } from '../../services/DiscourseService' import { ErrorService } from '../../services/ErrorService' import { FinancialService } from '../../services/FinancialService' -import Time from '../../utils/date/Time' +import { ProjectService } from '../../services/ProjectService' +import { VestingService } from '../../services/VestingService' import { ErrorCategory } from '../../utils/errorCategories' -import { CoauthorService } from '../services/coauthor' +import type { Project } from '../models/Project' import { UpdateService } from '../services/update' +import { validateIsAuthorOrCoauthor } from '../utils/validations' export default routes((route) => { const withAuth = auth() + route.get('/updates', handleAPI(getProjectUpdates)) + route.post('/updates', withAuth, handleAPI(createProjectUpdate)) route.get('/updates/financials', handleAPI(getAllFinancialRecords)) - route.get('/updates/:update_id', handleAPI(getProposalUpdate)) - route.patch('/updates/:update_id', withAuth, handleAPI(updateProposalUpdate)) - route.delete('/updates/:update_id', withAuth, handleAPI(deleteProposalUpdate)) - route.get('/updates/:update_id/comments', handleAPI(getProposalUpdateComments)) + route.get('/updates/:update_id', handleAPI(getProjectUpdate)) + route.patch('/updates/:update_id', withAuth, handleAPI(updateProjectUpdate)) + route.delete('/updates/:update_id', withAuth, handleAPI(deleteProjectUpdate)) + route.get('/updates/:update_id/comments', handleAPI(getProjectUpdateComments)) }) async function getAllFinancialRecords(req: Request<{ page_number: string; page_size: string }>) { @@ -48,7 +59,7 @@ async function getAllFinancialRecords(req: Request<{ page_number: string; page_s return await FinancialService.getAll(pageNumber, pageSize) } -async function getProposalUpdate(req: Request<{ update_id: string }>) { +async function getProjectUpdate(req: Request<{ update_id: string }>) { const id = req.params.update_id if (!id) { @@ -64,7 +75,7 @@ async function getProposalUpdate(req: Request<{ update_id: string }>) { return update } -async function getProposalUpdateComments(req: Request<{ update_id: string }>) { +async function getProjectUpdateComments(req: Request<{ update_id: string }>) { const update = await UpdateService.getById(req.params.update_id) if (!update) { throw new RequestError('Update not found', RequestError.NotFound) @@ -92,7 +103,7 @@ async function getProposalUpdateComments(req: Request<{ update_id: string }>) { } const generalSectionValidator = schema.compile(GeneralUpdateSectionSchema) -async function updateProposalUpdate(req: WithAuth>) { +async function updateProjectUpdate(req: WithAuth>) { const id = req.params.update_id const { author, financial_records, ...body } = req.body const { health, introduction, highlights, blockers, next_steps, additional_notes } = validate( @@ -110,25 +121,14 @@ async function updateProposalUpdate(req: WithAuth throw new RequestError(`Update not found: "${id}"`, RequestError.NotFound) } - const user = req.auth - - const proposal = await ProposalModel.findOne({ id: update.proposal_id }) - const isAuthorOrCoauthor = - user && (proposal?.user === user || (await CoauthorService.isCoauthor(update.proposal_id, user))) && author === user + const user = req.auth! - if (!proposal || !isAuthorOrCoauthor) { - throw new RequestError(`Unauthorized`, RequestError.Forbidden) - } + const project = await ProjectService.getUpdatedProject(update.project_id) + await validateIsAuthorOrCoauthor(user, project.id) - const now = new Date() - const isOnTime = Time(now).isBefore(update.due_date) - - if (!isOnTime && !isBetweenLateThresholdDate(update.due_date)) { - throw new RequestError(`Update is not on time: "${update.id}"`, RequestError.BadRequest) - } - - return await UpdateService.updateProposalUpdate( + return await UpdateService.updateProjectUpdate( update, + project, { author, health, @@ -139,15 +139,11 @@ async function updateProposalUpdate(req: WithAuth additional_notes, financial_records: parsedRecords, }, - id, - proposal, - user!, - now, - isOnTime + user! ) } -async function deleteProposalUpdate(req: WithAuth>) { +async function deleteProjectUpdate(req: WithAuth>) { const id = req.params.update_id if (!id || typeof id !== 'string') { throw new RequestError(`Missing or invalid id`, RequestError.BadRequest) @@ -159,23 +155,100 @@ async function deleteProposalUpdate(req: WithAuth throw new RequestError(`Update not found: "${id}"`, RequestError.NotFound) } + const user = req.auth! + await validateIsAuthorOrCoauthor(user, update.project_id) + if (!update.completion_date) { throw new RequestError(`Update is not completed: "${update.id}"`, RequestError.BadRequest) } - const user = req.auth - const proposal = await ProposalModel.findOne({ id: update.proposal_id }) - - const isAuthorOrCoauthor = - user && (proposal?.user === user || (await CoauthorService.isCoauthor(update.proposal_id, user))) - - if (!proposal || !isAuthorOrCoauthor) { - throw new RequestError(`Unauthorized`, RequestError.Forbidden) - } - await FinancialService.deleteRecordsByUpdateId(update.id) await UpdateService.delete(update) UpdateService.commentUpdateDeleteInDiscourse(update) return true } + +function parseFinancialRecords(financial_records: unknown) { + const parsedResult = FinancialUpdateSectionSchema.safeParse({ financial_records }) + if (!parsedResult.success) { + ErrorService.report('Submission of invalid financial records', { + error: parsedResult.error, + category: ErrorCategory.Financial, + }) + throw new RequestError(`Invalid financial records`, RequestError.BadRequest) + } + return parsedResult.data.financial_records +} + +async function validateFinancialRecords( + project: Project, + financial_records: unknown +): Promise { + const { id, vesting_addresses } = project + const [vestingData, updates] = await Promise.all([ + VestingService.getVestings(vesting_addresses), + UpdateService.getAllByProjectId(id), + ]) + + const releases = vestingData ? getReleases(vestingData) : undefined + const publicUpdates = getPublicUpdates(updates) + const latestUpdate = getLatestUpdate(publicUpdates || []) + const { releasedFunds } = getFundsReleasedSinceLatestUpdate(latestUpdate, releases) + return releasedFunds > 0 ? parseFinancialRecords(financial_records) : null +} + +async function createProjectUpdate(req: WithAuth) { + const user = req.auth! + const { project_id, author, financial_records, ...body } = req.body + const { health, introduction, highlights, blockers, next_steps, additional_notes } = validate( + schema.compile(GeneralUpdateSectionSchema), + body + ) + try { + const project = await ProjectService.getUpdatedProject(project_id) + await validateIsAuthorOrCoauthor(user, project.id) + + const financialRecords = await validateFinancialRecords(project, financial_records) + return await UpdateService.create( + { + author, + health, + introduction, + highlights, + blockers, + next_steps, + additional_notes, + financial_records: financialRecords, + }, + project, + user + ) + } catch (error) { + ErrorService.report('Error creating update', { + error, + category: ErrorCategory.Update, + }) + throw new RequestError(`Something went wrong: ${error}`, RequestError.InternalServerError) + } +} + +async function getProjectUpdates(req: Request) { + const project_id = req.query.project_id as string + if (!project_id) { + throw new RequestError(`Project not found: "${project_id}"`, RequestError.NotFound) + } + + const updates = await UpdateService.getAllByProjectId(project_id) + const publicUpdates = getPublicUpdates(updates) + const nextUpdate = getNextPendingUpdate(updates) + const currentUpdate = getCurrentUpdate(updates) + const pendingUpdates = getPendingUpdates(updates) + + return { + publicUpdates, + pendingUpdates, + nextUpdate, + currentUpdate, + } +} diff --git a/src/back/routes/vestings.ts b/src/back/routes/vestings.ts index c8db39cfb..a7ac22ddd 100644 --- a/src/back/routes/vestings.ts +++ b/src/back/routes/vestings.ts @@ -2,22 +2,22 @@ import handleAPI from 'decentraland-gatsby/dist/entities/Route/handle' import routes from 'decentraland-gatsby/dist/entities/Route/routes' import { Request } from 'express' -import { VestingInfo } from '../../clients/VestingData' +import { VestingWithLogs } from '../../clients/VestingData' import { VestingService } from '../../services/VestingService' import { validateAddress } from '../utils/validations' export default routes((router) => { router.get('/all-vestings', handleAPI(getAllVestings)) - router.post('/vesting', handleAPI(getVestingInfo)) + router.post('/vesting', handleAPI(getVestings)) }) async function getAllVestings() { return await VestingService.getAllVestings() } -async function getVestingInfo(req: Request): Promise { +async function getVestings(req: Request): Promise { const addresses = req.body.addresses addresses.forEach(validateAddress) - return await VestingService.getVestingInfo(addresses) + return await VestingService.getVestings(addresses) } diff --git a/src/back/routes/votes.ts b/src/back/routes/votes.ts index b3fe39a48..54be64176 100644 --- a/src/back/routes/votes.ts +++ b/src/back/routes/votes.ts @@ -15,7 +15,7 @@ import { ProposalService } from '../../services/ProposalService' import { SnapshotService } from '../../services/SnapshotService' import Time from '../../utils/date/Time' import { VoteService } from '../services/vote' -import { validateAddress, validateProposalId } from '../utils/validations' +import { validateAddress, validateId } from '../utils/validations' export default routes((route) => { route.get('/proposals/:proposal/votes', handleAPI(getVotesByProposal)) @@ -27,7 +27,7 @@ export default routes((route) => { export async function getVotesByProposal(req: Request<{ proposal: string }>) { const refresh = req.query.refresh === 'true' - const id = validateProposalId(req.params.proposal) + const id = validateId(req.params.proposal) const proposal = await ProposalService.getProposal(id) const latestVotes = await VoteService.getVotes(proposal.id) diff --git a/src/back/services/discord.ts b/src/back/services/discord.ts index 5071ccbab..247a60a72 100644 --- a/src/back/services/discord.ts +++ b/src/back/services/discord.ts @@ -5,8 +5,6 @@ import { getProfileUrl } from '../../entities/Profile/utils' import { ProposalWithOutcome } from '../../entities/Proposal/outcome' import { ProposalStatus, ProposalType } from '../../entities/Proposal/types' import { isGovernanceProcessProposal, proposalUrl } from '../../entities/Proposal/utils' -import UpdateModel from '../../entities/Updates/model' -import { UpdateAttributes } from '../../entities/Updates/types' import { getPublicUpdates, getUpdateNumber, getUpdateUrl } from '../../entities/Updates/utils' import UserModel from '../../entities/User/model' import { getEnumDisplayName, inBackground } from '../../helpers' @@ -15,6 +13,8 @@ import { getProfile } from '../../utils/Catalyst' import { ErrorCategory } from '../../utils/errorCategories' import { isProdEnv } from '../../utils/governanceEnvs' +import { UpdateService } from './update' + const CHANNEL_ID = process.env.DISCORD_CHANNEL_ID const PROFILE_VERIFICATION_CHANNEL_ID = process.env.DISCORD_PROFILE_VERIFICATION_CHANNEL_ID || '' const TOKEN = process.env.DISCORD_TOKEN @@ -219,7 +219,7 @@ export class DiscordService { if (DISCORD_SERVICE_ENABLED) { inBackground(async () => { try { - const publicUpdates = getPublicUpdates(await UpdateModel.find({ proposal_id: proposalId })) + const publicUpdates = getPublicUpdates(await UpdateService.getAllByProposalId(proposalId)) const updateNumber = getUpdateNumber(publicUpdates, updateId) const updateIdx = publicUpdates.length - updateNumber diff --git a/src/back/services/update.ts b/src/back/services/update.ts index 4ff4dcf3a..ba0e6047b 100644 --- a/src/back/services/update.ts +++ b/src/back/services/update.ts @@ -1,48 +1,75 @@ +import crypto from 'crypto' import logger from 'decentraland-gatsby/dist/entities/Development/logger' import RequestError from 'decentraland-gatsby/dist/entities/Route/error' import { Discourse } from '../../clients/Discourse' -import ProposalModel from '../../entities/Proposal/model' +import { VestingWithLogs, getVestingWithLogs } from '../../clients/VestingData' import { ProposalAttributes } from '../../entities/Proposal/types' import UpdateModel from '../../entities/Updates/model' import { UpdateAttributes, UpdateStatus } from '../../entities/Updates/types' -import { getCurrentUpdate, getNextPendingUpdate, getUpdateUrl } from '../../entities/Updates/utils' +import { + getCurrentUpdate, + getNextPendingUpdate, + getUpdateUrl, + isBetweenLateThresholdDate, +} from '../../entities/Updates/utils' import { inBackground } from '../../helpers' import { DiscourseService } from '../../services/DiscourseService' import { ErrorService } from '../../services/ErrorService' import { FinancialService } from '../../services/FinancialService' +import { ProjectService } from '../../services/ProjectService' import { DiscoursePost } from '../../shared/types/discourse' +import Time from '../../utils/date/Time' +import { getMonthsBetweenDates } from '../../utils/date/getMonthsBetweenDates' import { ErrorCategory } from '../../utils/errorCategories' +import { Project } from '../models/Project' -import { CoauthorService } from './coauthor' import { DiscordService } from './discord' import { EventsService } from './events' +interface Ids { + id: UpdateAttributes['id'] + proposal_id: ProposalAttributes['id'] + project_id: Project['id'] +} + export class UpdateService { - static async getById(id: UpdateAttributes['id']): Promise { + public static getDueDate(startingDate: Time.Dayjs, index: number) { + return startingDate.add(1 + index, 'months').toDate() + } + + private static async getAllById(ids: Partial, status?: UpdateStatus) { try { - const update = await UpdateModel.findOne({ id }) - if (update) { + return await UpdateModel.find(status ? { ...ids, status } : ids) + } catch (error) { + ErrorService.report('Error fetching updates', { ids, error, category: ErrorCategory.Update }) + return [] + } + } + + static async getById(id: UpdateAttributes['id']): Promise { + const updates = await this.getAllById({ id }) + const update = updates[0] + if (update) { + try { const financial_records = await FinancialService.getRecordsByUpdateId(update.id) - if (financial_records) { - return { ...update, financial_records } - } + return { ...update, financial_records } + } catch (error) { + ErrorService.report('Error fetching financial records', { id, error, category: ErrorCategory.Update }) + return update } - return update - } catch (error) { - ErrorService.report('Error fetching update', { id, error, category: ErrorCategory.Update }) - return null } + return update } static async getAllByProposalId(proposal_id: ProposalAttributes['id'], status?: UpdateStatus) { - try { - const parameters = { proposal_id } - return await UpdateModel.find(status ? { ...parameters, status } : parameters) - } catch (error) { - ErrorService.report('Error fetching updates', { proposal_id, error, category: ErrorCategory.Update }) - return [] - } + const updates = await this.getAllById({ proposal_id }, status) + return updates + } + + static async getAllByProjectId(project_id: Project['id'], status?: UpdateStatus) { + const updates = await this.getAllById({ project_id }, status) + return updates } static async updateWithDiscoursePost(id: UpdateAttributes['id'], discoursePost: DiscoursePost) { @@ -117,30 +144,16 @@ export class UpdateService { }) } - static async create(newUpdate: Omit, user: string) { - const { - proposal_id, - author, - health, - introduction, - highlights, - blockers, - next_steps, - additional_notes, - financial_records, - } = newUpdate - const proposal = await ProposalModel.findOne({ id: proposal_id }) - const isAuthorOrCoauthor = - user && (proposal?.user === user || (await CoauthorService.isCoauthor(proposal_id, user))) && author === user - - if (!proposal || !isAuthorOrCoauthor) { - throw new RequestError(`Unauthorized`, RequestError.Forbidden) - } + static async create( + newUpdate: Omit, + project: Project, + user: string + ) { + const { author, health, introduction, highlights, blockers, next_steps, additional_notes, financial_records } = + newUpdate - const updates = await UpdateModel.find({ - proposal_id, - status: UpdateStatus.Pending, - }) + const { proposal_id, title } = project + const updates = await this.getAllByProposalId(proposal_id, UpdateStatus.Pending) const currentUpdate = getCurrentUpdate(updates) const nextPendingUpdate = getNextPendingUpdate(updates) @@ -151,7 +164,8 @@ export class UpdateService { const data: Omit = { - proposal_id: proposal.id, + proposal_id, + project_id: project.id, author, health, introduction, @@ -163,9 +177,9 @@ export class UpdateService { const update = await UpdateModel.createUpdate(data) try { if (financial_records) await FinancialService.createRecords(update.id, financial_records) - await DiscourseService.createUpdate(update, proposal.title) - await EventsService.updateCreated(update.id, proposal.id, proposal.title, user) - DiscordService.newUpdate(proposal.id, proposal.title, update.id, user) + await DiscourseService.createUpdate(update, title) + await EventsService.updateCreated(update.id, proposal_id, title, user) + DiscordService.newUpdate(proposal_id, title, update.id, user) } catch (error) { await this.delete(update) throw new RequestError(`Error creating update`, RequestError.InternalServerError) @@ -174,18 +188,54 @@ export class UpdateService { return update } - static async updateProposalUpdate( + static async createPendingUpdatesForVesting(projectId: string, initialVestingAddresses?: string[]) { + if (projectId.length < 0) throw new Error('Unable to create updates for empty project id') + + const project = await ProjectService.getUpdatedProject(projectId) + const { vesting_addresses, proposal_id } = project + const vestingAddresses = initialVestingAddresses || vesting_addresses + const vesting = await getVestingWithLogs(vestingAddresses[vestingAddresses.length - 1], proposal_id) + + const now = new Date() + const updatesQuantity = this.getAmountOfUpdates(vesting) + const firstUpdateStartingDate = Time.utc(vesting.start_at).startOf('day') + + await UpdateModel.delete({ project_id: projectId, status: UpdateStatus.Pending }) + + const updates = Array.from(Array(updatesQuantity), (_, index) => { + const update: UpdateAttributes = { + id: crypto.randomUUID(), + proposal_id, + project_id: projectId, + status: UpdateStatus.Pending, + due_date: this.getDueDate(firstUpdateStartingDate, index), + created_at: now, + updated_at: now, + } + + return update + }) + return await UpdateModel.createMany(updates) + } + + static async updateProjectUpdate( update: UpdateAttributes, + project: Project, newUpdate: Omit< UpdateAttributes, - 'id' | 'proposal_id' | 'status' | 'completion_date' | 'updated_at' | 'created_at' + 'id' | 'proposal_id' | 'project_id' | 'status' | 'completion_date' | 'updated_at' | 'created_at' >, - id: string, - proposal: ProposalAttributes, - user: string, - now: Date, - isOnTime: boolean + user: string ) { + const id = update.id + + const now = new Date() + const isOnTime = Time(now).isBefore(update.due_date) + + if (!isOnTime && !isBetweenLateThresholdDate(update.due_date)) { + throw new Error(`Update is not on time: "${update.id}"`) + } + const status = !update.due_date || isOnTime ? UpdateStatus.Done : UpdateStatus.Late const { author, health, introduction, highlights, blockers, next_steps, additional_notes, financial_records } = newUpdate @@ -210,13 +260,14 @@ export class UpdateService { ) const updatedUpdate = await UpdateService.getById(id) + const { proposal_id, title } = project if (updatedUpdate) { if (!update.completion_date) { await Promise.all([ - DiscourseService.createUpdate(updatedUpdate, proposal.title), - EventsService.updateCreated(update.id, proposal.id, proposal.title, user), + DiscourseService.createUpdate(updatedUpdate, title), + EventsService.updateCreated(update.id, proposal_id, title, user), ]) - DiscordService.newUpdate(proposal.id, proposal.title, update.id, user) + DiscordService.newUpdate(proposal_id, title, update.id, user) } else { UpdateService.commentUpdateEditInDiscourse(updatedUpdate) } @@ -224,4 +275,9 @@ export class UpdateService { return true } + + static getAmountOfUpdates(vesting: VestingWithLogs) { + const exactDuration = getMonthsBetweenDates(new Date(vesting.start_at), new Date(vesting.finish_at)) + return exactDuration.months + (exactDuration.extraDays > 0 ? 1 : 0) + } } diff --git a/src/back/utils/validations.test.ts b/src/back/utils/validations.test.ts index 1f7768aee..5441edea4 100644 --- a/src/back/utils/validations.test.ts +++ b/src/back/utils/validations.test.ts @@ -2,25 +2,25 @@ import RequestError from 'decentraland-gatsby/dist/entities/Route/error' import { EventType } from '../../shared/types/events' -import { validateEventTypesFilters, validateProposalId } from './validations' +import { validateEventTypesFilters, validateId } from './validations' describe('validateProposalId', () => { const UUID = '00000000-0000-0000-0000-000000000000' it('should not throw an error for a valid proposal id', () => { - expect(() => validateProposalId(UUID)).not.toThrow() + expect(() => validateId(UUID)).not.toThrow() }) it('should throw an error for a missing required proposal id', () => { - expect(() => validateProposalId(undefined)).toThrow(RequestError) + expect(() => validateId(undefined)).toThrow(RequestError) }) it('should throw an error for an empty required proposal id', () => { - expect(() => validateProposalId('')).toThrow(RequestError) + expect(() => validateId('')).toThrow(RequestError) }) it('should throw an error for proposal id with spaces', () => { - expect(() => validateProposalId(' ')).toThrow(RequestError) + expect(() => validateId(' ')).toThrow(RequestError) }) }) diff --git a/src/back/utils/validations.ts b/src/back/utils/validations.ts index adbe4d604..c2cf82c02 100644 --- a/src/back/utils/validations.ts +++ b/src/back/utils/validations.ts @@ -7,8 +7,14 @@ 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 { ProjectStatus } from '../../entities/Grant/types' +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 { ProjectService } from '../../services/ProjectService' import { EventFilterSchema } from '../../shared/types/events' import { ErrorCategory } from '../../utils/errorCategories' @@ -65,9 +71,9 @@ export function validateProposalFields(fields: unknown) { } } -export function validateProposalId(id?: string) { +export function validateId(id?: string | null) { if (!(id && isUUID(id))) { - throw new RequestError('Invalid proposal id', RequestError.BadRequest) + throw new RequestError('Invalid id', RequestError.BadRequest) } return id } @@ -164,6 +170,48 @@ 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) + } + } +} + +export async function validateIsAuthorOrCoauthor(user: string, projectId: string) { + validateId(projectId) + validateAddress(user) + const isAuthorOrCoauthor = await ProjectService.isAuthorOrCoauthor(user, projectId) + if (!isAuthorOrCoauthor) { + throw new RequestError("User is not the project's author or coauthor", RequestError.Unauthorized) + } +} + +const NOT_EDITABLE_STATUS = new Set([ProjectStatus.Finished, ProjectStatus.Revoked]) +export async function validateCanEditProject(user: string, projectId: string) { + await validateIsAuthorOrCoauthor(user, projectId) + const project = await ProjectService.getUpdatedProject(projectId) + if (NOT_EDITABLE_STATUS.has(project.status)) { + throw new RequestError('Project cannot be edited after it is finished or revoked', RequestError.BadRequest) + } +} + export function validateBlockNumber(blockNumber?: unknown | null) { if (blockNumber !== null && blockNumber !== undefined && typeof blockNumber !== 'number') { throw new Error('Invalid blockNumber: must be null, undefined, or a number') diff --git a/src/clients/Governance.ts b/src/clients/Governance.ts index 003374a0b..0ccda22aa 100644 --- a/src/clients/Governance.ts +++ b/src/clients/Governance.ts @@ -1,91 +1,8 @@ -import snakeCase from 'lodash/snakeCase' - -import { AirdropJobAttributes } from '../back/models/AirdropJob' -import { AirdropOutcome } from '../back/types/AirdropJob' import env from '../config' import { GOVERNANCE_API } from '../constants' -import { BadgeCreationResult, GovernanceBadgeSpec, RevokeOrReinstateResult, UserBadges } from '../entities/Badges/types' -import { BidRequest, UnpublishedBidAttributes } from '../entities/Bid/types' -import { Budget, BudgetWithContestants, CategoryBudget } from '../entities/Budget/types' -import { CoauthorAttributes, CoauthorStatus } from '../entities/Coauthor/types' -import { GrantRequest, ProposalGrantCategory } from '../entities/Grant/types' -import { - NewProposalBanName, - NewProposalCatalyst, - NewProposalDraft, - NewProposalGovernance, - NewProposalHiring, - NewProposalLinkedWearables, - NewProposalPOI, - NewProposalPitch, - NewProposalPoll, - NewProposalTender, - PendingProposalsQuery, - PriorityProposal, - Project, - ProjectWithUpdate, - ProposalAttributes, - ProposalCommentsInDiscourse, - ProposalListFilter, - ProposalStatus, -} from '../entities/Proposal/types' -import { QuarterBudgetAttributes } from '../entities/QuarterBudget/types' -import { SubscriptionAttributes } from '../entities/Subscription/types' -import { Topic } from '../entities/SurveyTopic/types' -import { - UpdateAttributes, - UpdateFinancialSection, - UpdateGeneralSection, - UpdateResponse, - UpdateSubmissionDetails, -} from '../entities/Updates/types' -import { AccountType } from '../entities/User/types' -import { Participation, VoteByAddress, VotedProposal, Voter, VotesForProposals } from '../entities/Votes/types' -import { ActivityTickerEvent, EventType } from '../shared/types/events' -import { NewsletterSubscriptionResult } from '../shared/types/newsletter' -import { PushNotification } from '../shared/types/notifications' -import Time from '../utils/date/Time' import API, { ApiOptions } from './API' import { ApiResponse } from './ApiResponse' -import { - DetailedScores, - SnapshotConfig, - SnapshotProposal, - SnapshotSpace, - SnapshotStatus, - SnapshotVote, - VpDistribution, -} from './SnapshotTypes' -import { TransparencyBudget, TransparencyVesting } from './Transparency' -import { VestingInfo } from './VestingData' - -type SpecState = { - title: string - description: string - expiresAt?: string - imgUrl: string -} - -type NewProposalMap = { - [`/proposals/poll`]: NewProposalPoll - [`/proposals/draft`]: NewProposalDraft - [`/proposals/governance`]: NewProposalGovernance - [`/proposals/ban-name`]: NewProposalBanName - [`/proposals/poi`]: NewProposalPOI - [`/proposals/catalyst`]: NewProposalCatalyst - [`/proposals/grant`]: GrantRequest - [`/proposals/linked-wearables`]: NewProposalLinkedWearables - [`/proposals/pitch`]: NewProposalPitch - [`/proposals/tender`]: NewProposalTender - [`/proposals/bid`]: BidRequest - [`/proposals/hiring`]: NewProposalHiring -} - -export type GetProposalsFilter = ProposalListFilter & { - limit: number - offset: number -} const getGovernanceApiUrl = () => { if (process.env.GATSBY_HEROKU_APP_NAME) { @@ -116,297 +33,6 @@ export class Governance extends API { return (await this.fetch>(endpoint, options)).data } - static parseProposal(proposal: ProposalAttributes): ProposalAttributes { - return { - ...proposal, - start_at: Time.date(proposal.start_at), - finish_at: Time.date(proposal.finish_at), - updated_at: Time.date(proposal.updated_at), - created_at: Time.date(proposal.created_at), - } - } - - static parsePriorityProposal(proposal: PriorityProposal): PriorityProposal { - return { - ...proposal, - start_at: Time.date(proposal.start_at), - finish_at: Time.date(proposal.finish_at), - } - } - - async getProposal(proposalId: string) { - const result = await this.fetch>(`/proposals/${proposalId}`) - return result.data ? Governance.parseProposal(result.data) : null - } - - async getProposals(filters: Partial = {}) { - const query = this.toQueryString(filters) - - const proposals = await this.fetch & { total: number }>(`/proposals${query}`, { - method: 'GET', - sign: !!filters.subscribed, - }) - - return { - ...proposals, - data: proposals.data.map((proposal) => Governance.parseProposal(proposal)), - } - } - - async getProjects(from?: Date, to?: Date) { - const params = new URLSearchParams() - if (from) { - params.append('from', from.toISOString().split('T')[0]) - } - if (to) { - params.append('to', to.toISOString().split('T')[0]) - } - const paramsStr = params.toString() - const proposals = await this.fetchApiResponse(`/projects${paramsStr ? `?${paramsStr}` : ''}`) - - return proposals - } - - async getOpenPitchesTotal() { - return await this.fetch<{ total: number }>(`/projects/pitches-total`) - } - - async getOpenTendersTotal() { - return await this.fetch<{ total: number }>(`/projects/tenders-total`) - } - - async getPriorityProposals(address?: string) { - const url = `/proposals/priority/` - const proposals = await this.fetch(address && address.length > 0 ? url.concat(address) : url) - return proposals.map((proposal) => Governance.parsePriorityProposal(proposal)) - } - - async getGrantsByUser(user: string) { - return await this.fetchApiResponse<{ total: number; data: Project[] }>(`/proposals/grants/${user}`) - } - - async createProposal

(path: P, proposal: NewProposalMap[P]) { - return await this.fetchApiResponse(path, { - method: 'POST', - sign: true, - json: proposal, - }) - } - - async createProposalPoll(proposal: NewProposalPoll) { - return this.createProposal(`/proposals/poll`, proposal) - } - - async createProposalDraft(proposal: NewProposalDraft) { - return this.createProposal(`/proposals/draft`, proposal) - } - - async createProposalGovernance(proposal: NewProposalGovernance) { - return this.createProposal(`/proposals/governance`, proposal) - } - - async createProposalBanName(proposal: NewProposalBanName) { - return this.createProposal(`/proposals/ban-name`, proposal) - } - - async createProposalPOI(proposal: NewProposalPOI) { - return this.createProposal(`/proposals/poi`, proposal) - } - - async createProposalCatalyst(proposal: NewProposalCatalyst) { - return this.createProposal(`/proposals/catalyst`, proposal) - } - - async createProposalGrant(proposal: GrantRequest) { - return this.createProposal(`/proposals/grant`, proposal) - } - - async createProposalLinkedWearables(proposal: NewProposalLinkedWearables) { - return this.createProposal(`/proposals/linked-wearables`, proposal) - } - - async createProposalPitch(proposal: NewProposalPitch) { - return this.createProposal(`/proposals/pitch`, proposal) - } - - async createProposalTender(proposal: NewProposalTender) { - return this.createProposal(`/proposals/tender`, proposal) - } - - async createProposalBid(proposal: BidRequest) { - return this.createProposal(`/proposals/bid`, proposal) - } - - async createProposalHiring(proposal: NewProposalHiring) { - return this.createProposal(`/proposals/hiring`, proposal) - } - - async deleteProposal(proposal_id: string) { - return await this.fetchApiResponse(`/proposals/${proposal_id}`, { method: 'DELETE', sign: true }) - } - - async updateProposalStatus(proposal_id: string, status: ProposalStatus, vesting_addresses?: string[]) { - const proposal = await this.fetchApiResponse(`/proposals/${proposal_id}`, { - method: 'PATCH', - sign: true, - json: { status, vesting_addresses }, - }) - - return Governance.parseProposal(proposal) - } - - async getProposalUpdate(update_id: string) { - return await this.fetchApiResponse(`/updates/${update_id}`) - } - - async getProposalUpdates(proposal_id: string) { - return await this.fetchApiResponse(`/proposals/${proposal_id}/updates`) - } - - async createProposalUpdate( - proposal_id: string, - update: UpdateSubmissionDetails & UpdateGeneralSection & UpdateFinancialSection - ) { - return await this.fetchApiResponse(`/proposals/${proposal_id}/update`, { - method: 'POST', - sign: true, - json: update, - }) - } - - async updateProposalUpdate( - update_id: string, - update: UpdateSubmissionDetails & UpdateGeneralSection & UpdateFinancialSection - ) { - return await this.fetchApiResponse(`/updates/${update_id}`, { - method: 'PATCH', - sign: true, - json: update, - }) - } - - async deleteProposalUpdate(update_id: UpdateAttributes['id']) { - return await this.fetchApiResponse(`/updates/${update_id}`, { - method: 'DELETE', - sign: true, - }) - } - - async getVotesByProposal(proposal_id: string) { - return await this.fetchApiResponse(`/proposals/${proposal_id}/votes`) - } - - async getCachedVotesByProposals(proposal_ids: string[]) { - if (proposal_ids.length === 0) { - return {} - } - - const params = proposal_ids.reduce((result, id) => { - result.append('id', id) - return result - }, new URLSearchParams()) - - return await this.fetchApiResponse(`/votes?${params.toString()}`) - } - - async getVotesAndProposalsByAddress(address: string, first?: number, skip?: number) { - return await this.fetchApiResponse(`/votes/${address}?first=${first}&skip=${skip}`) - } - - async getTopVotersForLast30Days() { - return await this.fetchApiResponse(`/votes/top-voters`) - } - - async getParticipation() { - return await this.fetchApiResponse(`/votes/participation`) - } - - async getUserSubscriptions() { - return await this.fetchApiResponse(`/subscriptions`, { method: 'GET', sign: true }) - } - - async getSubscriptions(proposal_id: string) { - return await this.fetchApiResponse(`/proposals/${proposal_id}/subscriptions`) - } - - async subscribe(proposal_id: string) { - return await this.fetchApiResponse(`/proposals/${proposal_id}/subscriptions`, { - method: 'POST', - sign: true, - }) - } - - async unsubscribe(proposal_id: string) { - return await this.fetchApiResponse(`/proposals/${proposal_id}/subscriptions`, { - method: 'DELETE', - sign: true, - }) - } - - async getCommittee() { - return await this.fetchApiResponse(`/committee`) - } - - async getDebugAddresses() { - return await this.fetchApiResponse(`/debug`) - } - - async getProposalComments(proposal_id: string) { - return await this.fetchApiResponse(`/proposals/${proposal_id}/comments`) - } - - async getProposalsByCoAuthor(address: string, status?: CoauthorStatus) { - return await this.fetchApiResponse( - `/coauthors/proposals/${address}${status ? `/${status}` : ''}` - ) - } - - async getCoAuthorsByProposal(id: string, status?: CoauthorStatus) { - if (!id) { - return [] - } - return await this.fetchApiResponse(`/coauthors/${id}${status ? `/${status}` : ''}`) - } - - async updateCoauthorStatus(proposalId: string, status: CoauthorStatus) { - return await this.fetchApiResponse(`/coauthors/${proposalId}`, { - method: 'PUT', - sign: true, - json: { status }, - }) - } - - async checkImage(imageUrl: string) { - return await this.fetchApiResponse(`/proposals/linked-wearables/image?url=${imageUrl}`) - } - - async getCategoryBudget(category: ProposalGrantCategory): Promise { - return await this.fetchApiResponse(`/budget/${snakeCase(category)}`) - } - - async getTransparencyBudgets() { - return await this.fetchApiResponse(`/budget/fetch`) - } - - async getCurrentBudget() { - return await this.fetchApiResponse(`/budget/current`) - } - - async getAllBudgets() { - return await this.fetchApiResponse(`/budget/all`) - } - - async getBudgetWithContestants(proposalId: string) { - return await this.fetchApiResponse(`/budget/contested/${proposalId}`) - } - - async updateGovernanceBudgets() { - return await this.fetchApiResponse(`/budget/update`, { - method: 'POST', - sign: true, - }) - } - async reportErrorToServer(message: string, extraInfo?: Record) { return await this.fetchApiResponse(`/debug/report-error`, { method: 'POST', @@ -415,265 +41,7 @@ export class Governance extends API { }) } - async triggerFunction(functionName: string) { - return await this.fetchApiResponse(`/debug/trigger`, { - method: 'POST', - sign: true, - json: { functionName }, - }) - } - - async invalidateCache(key: string) { - return await this.fetchApiResponse(`/debug/invalidate-cache`, { - method: 'DELETE', - sign: true, - json: { key }, - }) - } - - async checkUrlTitle(url: string) { - return await this.fetchApiResponse<{ title?: string }>(`/url-title`, { method: 'POST', json: { url } }) - } - - async getSurveyTopics(proposalId: string) { - return await this.fetchApiResponse(`/proposals/${proposalId}/survey-topics`) - } - - async getValidationMessage(account?: AccountType) { - const params = new URLSearchParams() - if (account) { - params.append('account', account) - } - return await this.fetchApiResponse(`/user/validate?${params.toString()}`, { - method: 'GET', - sign: true, - }) - } - - async validateForumProfile() { - return await this.fetchApiResponse<{ valid: boolean }>('/user/validate/forum', { method: 'POST', sign: true }) - } - - async validateDiscordProfile() { - return await this.fetchApiResponse<{ valid: boolean }>('/user/validate/discord', { method: 'POST', sign: true }) - } - - async isProfileValidated(address: string, accounts: AccountType[]) { - const params = new URLSearchParams() - for (const account of accounts) { - params.append('account', account) - } - return await this.fetchApiResponse(`/user/${address}/is-validated/?${params.toString()}`) - } - - async isDiscordActive() { - return await this.fetchApiResponse(`/user/discord-active`, { method: 'GET', sign: true }) - } - - async isDiscordLinked() { - return await this.fetchApiResponse(`/user/discord-linked`, { method: 'GET', sign: true }) - } - - async updateDiscordStatus(is_discord_notifications_active: boolean) { - return await this.fetchApiResponse(`/user/discord-active`, { - method: 'POST', - sign: true, - json: { is_discord_notifications_active }, - }) - } - - async getUserProfile(address: string) { - return await this.fetchApiResponse<{ forum_id: number | null; forum_username: string | null }>(`/user/${address}`) - } - - async getBadges(address: string) { - return await this.fetchApiResponse(`/badges/${address}`) - } - - async getCoreUnitsBadges() { - return await this.fetchApiResponse(`/badges/core-units`) - } - - async getBidsInfoOnTender(tenderId: string) { - return await this.fetchApiResponse<{ is_submission_window_finished: boolean; publish_at: string }>( - `/bids/${tenderId}` - ) - } - - async getUserBidOnTender(tenderId: string) { - return await this.fetchApiResponse | null>(`/bids/${tenderId}/get-user-bid`, { method: 'GET', sign: true }) - } - - async getSnapshotConfigAndSpace(spaceName?: string) { - return await this.fetchApiResponse<{ config: SnapshotConfig; space: SnapshotSpace }>( - `/snapshot/config/${spaceName}` - ) - } - - async getSnapshotStatus() { - return await this.fetchApiResponse(`/snapshot/status`) - } - - async getVotesByAddresses(addresses: string[]) { - return await this.fetchApiResponse(`/snapshot/votes/`, { - method: 'POST', - json: { addresses }, - }) - } - - async getVotesByProposalFromSnapshot(proposalId: string) { - return await this.fetchApiResponse(`/snapshot/votes/${proposalId}`) - } - - async getSnapshotProposals(start: Date, end: Date, fields: (keyof SnapshotProposal)[]) { - return await this.fetchApiResponse[]>(`/snapshot/proposals`, { - method: 'POST', - json: { start, end, fields }, - }) - } - - async getPendingProposals(query: PendingProposalsQuery) { - return await this.fetchApiResponse[]>(`/snapshot/proposals/pending`, { - method: 'POST', - json: query, - }) - } - - async getProposalScores(proposalSnapshotId: string) { - return await this.fetchApiResponse(`/snapshot/proposal-scores/${proposalSnapshotId}`) - } - - async getVpDistribution(address: string, proposalSnapshotId?: string) { - const snapshotId = proposalSnapshotId ? `/${proposalSnapshotId}` : '' - const url = `/snapshot/vp-distribution/${address}${snapshotId}` - return await this.fetchApiResponse(url) - } - - async getScores(addresses: string[]) { - return await this.fetchApiResponse('/snapshot/scores', { method: 'POST', json: { addresses } }) - } - - async getAllVestings() { - return await this.fetchApiResponse(`/all-vestings`) - } - - async getVestingContractData(addresses: string[]) { - return await this.fetchApiResponse(`/vesting`, { method: 'POST', json: { addresses } }) - } - - async getUpdateComments(update_id: string) { - return await this.fetchApiResponse(`/updates/${update_id}/comments`) - } - - async airdropBadge(badgeSpecCid: string, recipients: string[]) { - return await this.fetchApiResponse(`/badges/airdrop/`, { - method: 'POST', - sign: true, - json: { - badgeSpecCid, - recipients, - }, - }) - } - - async revokeBadge(badgeSpecCid: string, recipients: string[], reason?: string) { - return await this.fetchApiResponse(`/badges/revoke/`, { - method: 'POST', - sign: true, - json: { - badgeSpecCid, - recipients, - reason, - }, - }) - } - - async uploadBadgeSpec(spec: SpecState) { - return await this.fetchApiResponse(`/badges/upload-badge-spec/`, { - method: 'POST', - sign: true, - json: { - spec, - }, - }) - } - - async createBadgeSpec(badgeCid: string) { - return await this.fetchApiResponse(`/badges/create-badge-spec/`, { - method: 'POST', - sign: true, - json: { - badgeCid, - }, - }) - } - - async subscribeToNewsletter(email: string) { - return await this.fetchApiResponse(`/newsletter-subscribe`, { - method: 'POST', - json: { - email, - }, - }) - } - - async getUserNotifications(address: string) { - return await this.fetchApiResponse(`/notifications/user/${address}`) - } - - async sendNotification(recipient: string, title: string, body: string, type: number, url: string) { - return await this.fetchApiResponse(`/notifications/send`, { - method: 'POST', - sign: true, - json: { - recipient, - title, - body, - type, - url, - }, - }) - } - - async getUserLastNotification() { - return await this.fetchApiResponse(`/notifications/last-notification`, { - method: 'GET', - sign: true, - }) - } - - async updateUserLastNotification(last_notification_id: number) { - return await this.fetchApiResponse(`/notifications/last-notification`, { - method: 'POST', - sign: true, - json: { last_notification_id }, - }) - } - - async getLatestEvents(eventTypes: EventType[]) { - const query = this.toQueryString({ event_type: eventTypes }) - return await this.fetchApiResponse(`/events${query}`) - } - - async createVoteEvent(proposalId: string, proposalTitle: string, choice: string) { - return await this.fetchApiResponse(`/events/voted`, { - method: 'POST', - sign: true, - json: { proposalId, proposalTitle, choice }, - }) - } - - async getAllEvents() { - return await this.fetchApiResponse(`/events/all`, { method: 'GET', sign: true }) - } - - async getAllAirdropJobs() { - return await this.fetchApiResponse(`/airdrops/all`, { - method: 'GET', - sign: true, - }) + async checkImage(imageUrl: string) { + return await this.fetchApiResponse(`/proposals/linked-wearables/image?url=${imageUrl}`) } } diff --git a/src/clients/Transparency.ts b/src/clients/Transparency.ts index db8758de4..92981fda6 100644 --- a/src/clients/Transparency.ts +++ b/src/clients/Transparency.ts @@ -1,4 +1,4 @@ -import { ProjectStatus } from '../entities/Grant/types' +import { VestingStatus } from '../entities/Grant/types' import { TokenInWallet } from '../entities/Transparency/types' import { ErrorCategory } from '../utils/errorCategories' @@ -63,7 +63,7 @@ export type TransparencyVesting = { vesting_finish_at: string vesting_contract_token_balance: number vesting_total_amount: number - vesting_status: ProjectStatus + vesting_status: VestingStatus duration_in_months: number } diff --git a/src/clients/VestingData.ts b/src/clients/VestingData.ts index fd56461c2..67d46de06 100644 --- a/src/clients/VestingData.ts +++ b/src/clients/VestingData.ts @@ -2,6 +2,8 @@ import { ChainId } from '@dcl/schemas' import { JsonRpcProvider } from '@ethersproject/providers' import { BigNumber, ethers } from 'ethers' +import { VestingStatus } from '../entities/Grant/types' +import { ErrorService } from '../services/ErrorService' import RpcService from '../services/RpcService' import ERC20_ABI from '../utils/contracts/abi/ERC20.abi.json' import VESTING_ABI from '../utils/contracts/abi/vesting/vesting.json' @@ -9,30 +11,25 @@ import VESTING_V2_ABI from '../utils/contracts/abi/vesting/vesting_v2.json' import { ContractVersion, TopicsByVersion } from '../utils/contracts/vesting' import { ErrorCategory } from '../utils/errorCategories' -import { ErrorClient } from './ErrorClient' - -export type VestingDates = { - vestingStartAt: string - vestingFinishAt: string +export type VestingLog = { + topic: string + timestamp: string + amount?: number } -export type VestingValues = { +export type Vesting = { + start_at: string + finish_at: string released: number releasable: number + vested: number total: number + address: string + status: VestingStatus + token: string } -export type VestingLog = { - topic: string - timestamp: string - amount?: number -} - -export type VestingInfo = VestingDates & - VestingValues & { - address: string - logs: VestingLog[] - } +export type VestingWithLogs = Vesting & { logs: VestingLog[] } function toISOString(seconds: number) { return new Date(seconds * 1000).toISOString() @@ -87,14 +84,33 @@ async function getVestingContractLogs(vestingAddress: string, provider: JsonRpcP return logsData } +function getInitialVestingStatus(startAt: string, finishAt: string) { + const now = new Date() + if (now < new Date(startAt)) { + return VestingStatus.Pending + } + if (now < new Date(finishAt)) { + return VestingStatus.InProgress + } + return VestingStatus.Finished +} + async function getVestingContractDataV1( vestingAddress: string, provider: ethers.providers.JsonRpcProvider -): Promise { +): Promise> { const vestingContract = new ethers.Contract(vestingAddress, VESTING_ABI, provider) const contractStart = Number(await vestingContract.start()) const contractDuration = Number(await vestingContract.duration()) const contractEndsTimestamp = contractStart + contractDuration + const start_at = toISOString(contractStart) + const finish_at = toISOString(contractEndsTimestamp) + + let status = getInitialVestingStatus(start_at, finish_at) + const isRevoked = await vestingContract.methods.revoked().call() + if (isRevoked) { + status = VestingStatus.Revoked + } const released = parseContractValue(await vestingContract.released()) const releasable = parseContractValue(await vestingContract.releasableAmount()) @@ -102,24 +118,39 @@ async function getVestingContractDataV1( const tokenContractAddress = await vestingContract.token() const tokenContract = new ethers.Contract(tokenContractAddress, ERC20_ABI, provider) const total = parseContractValue(await tokenContract.balanceOf(vestingAddress)) + released + const token = getTokenSymbolFromAddress(tokenContractAddress.toLowerCase()) - return { ...getVestingDates(contractStart, contractEndsTimestamp), released, releasable, total } + return { + ...getVestingDates(contractStart, contractEndsTimestamp), + released, + releasable, + total, + status, + start_at, + finish_at, + token, + vested: released + releasable, + } } async function getVestingContractDataV2( vestingAddress: string, provider: ethers.providers.JsonRpcProvider -): Promise { +): Promise> { const vestingContract = new ethers.Contract(vestingAddress, VESTING_V2_ABI, provider) const contractStart = Number(await vestingContract.getStart()) const contractDuration = Number(await vestingContract.getPeriod()) - let contractEndsTimestamp = 0 + let contractEndsTimestamp = 0 + const start_at = toISOString(contractStart) + let finish_at = '' if (await vestingContract.getIsLinear()) { contractEndsTimestamp = contractStart + contractDuration + finish_at = toISOString(contractEndsTimestamp) } else { const periods = (await vestingContract.getVestedPerPeriod()).length || 0 contractEndsTimestamp = contractStart + contractDuration * periods + finish_at = toISOString(contractEndsTimestamp) } const released = parseContractValue(await vestingContract.getReleased()) @@ -128,13 +159,37 @@ async function getVestingContractDataV2( const total = vestedPerPeriod.map(parseContractValue).reduce((acc, curr) => acc + curr, 0) - return { ...getVestingDates(contractStart, contractEndsTimestamp), released, releasable, total } + let status = getInitialVestingStatus(start_at, finish_at) + const isRevoked = await vestingContract.getIsRevoked() + if (isRevoked) { + status = VestingStatus.Revoked + } else { + const isPaused = await vestingContract.paused() + if (isPaused) { + status = VestingStatus.Paused + } + } + + const tokenContractAddress: string = (await vestingContract.getToken()).toLowerCase() + const token = getTokenSymbolFromAddress(tokenContractAddress) + + return { + ...getVestingDates(contractStart, contractEndsTimestamp), + released, + releasable, + total, + status, + start_at, + finish_at, + token, + vested: released + releasable, + } } -export async function getVestingContractData( +export async function getVestingWithLogs( vestingAddress: string | null | undefined, proposalId?: string -): Promise { +): Promise { if (!vestingAddress || vestingAddress.length === 0) { throw new Error('Unable to fetch vesting data for empty contract address') } @@ -161,7 +216,7 @@ export async function getVestingContractData( address: vestingAddress, } } catch (errorV1) { - ErrorClient.report('Unable to fetch vesting contract data', { + ErrorService.report('Unable to fetch vesting contract data', { proposalId, error: errorV1, category: ErrorCategory.Vesting, @@ -170,3 +225,21 @@ export async function getVestingContractData( } } } + +function getTokenSymbolFromAddress(tokenAddress: string) { + switch (tokenAddress) { + case '0x0f5d2fb29fb7d3cfee444a200298f468908cc942': + return 'MANA' + case '0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0': + return 'MATIC' + case '0x6b175474e89094c44da98b954eedeac495271d0f': + return 'DAI' + case '0xdac17f958d2ee523a2206206994597c13d831ec7': + return 'USDT' + case '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': + return 'USDC' + case '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2': + return 'WETH' + } + throw new Error(`Unable to parse token contract address: ${tokenAddress}`) +} diff --git a/src/entities/Bid/types.ts b/src/entities/Bid/types.ts index 979e028d5..b6019e70a 100644 --- a/src/entities/Bid/types.ts +++ b/src/entities/Bid/types.ts @@ -1,4 +1,12 @@ -import { BudgetBreakdownConcept, GrantRequestDueDiligenceSchema, GrantRequestTeamSchema } from '../Grant/types' +import { + BudgetBreakdownConcept, + GrantRequestDueDiligenceSchema, + GrantRequestTeamSchema, + Milestone, + MilestoneItemSchema, + ProposalRequestTeam, +} from '../Grant/types' +import { MILESTONE_SUBMIT_LIMIT } from '../Proposal/constants' import { BID_MIN_PROJECT_DURATION } from './constants' @@ -36,32 +44,26 @@ export type BidRequestGeneralInfo = { teamName: string deliverables: string roadmap: string + milestones: Milestone[] coAuthors?: string[] } -export type TeamMember = { - name: string - role: string - about: string - relevantLink?: string -} - -export type BidRequestTeam = { - members: TeamMember[] -} - export type BidRequestDueDiligence = { budgetBreakdown: BudgetBreakdownConcept[] } export type BidRequest = BidRequestFunding & BidRequestGeneralInfo & - BidRequestTeam & + ProposalRequestTeam & BidRequestDueDiligence & { linked_proposal_id: string coAuthors?: string[] } +export type BidProposalConfiguration = BidRequest & { bid_number: number } & { created_at: string } & { + choices: string[] +} + export const BidRequestFundingSchema = { funding: { type: 'integer', @@ -102,6 +104,16 @@ export const BidRequestGeneralInfoSchema = { minLength: 20, maxLength: 1500, }, + milestones: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: [...Object.keys(MilestoneItemSchema)], + properties: MilestoneItemSchema, + }, + maxItems: MILESTONE_SUBMIT_LIMIT, + }, coAuthors: { type: 'array', items: { diff --git a/src/entities/Grant/constants.ts b/src/entities/Grant/constants.ts index 969f7d9e1..e8c6c6cdd 100644 --- a/src/entities/Grant/constants.ts +++ b/src/entities/Grant/constants.ts @@ -1,5 +1,3 @@ -import env from 'decentraland-gatsby/dist/utils/env' - import Time from '../../utils/date/Time' export const GRANT_PROPOSAL_DURATION_IN_SECONDS = process.env.DURATION_GRANT || '1209600' diff --git a/src/entities/Grant/types.ts b/src/entities/Grant/types.ts index 485444443..8a4ebee83 100644 --- a/src/entities/Grant/types.ts +++ b/src/entities/Grant/types.ts @@ -1,6 +1,7 @@ import camelCase from 'lodash/camelCase' import cloneDeep from 'lodash/cloneDeep' +import { MILESTONE_SUBMIT_LIMIT } from '../Proposal/constants' import { toNewGrantCategory } from '../QuarterCategoryBudget/utils' export const GRANT_PROPOSAL_MIN_BUDGET = 100 @@ -60,9 +61,8 @@ export enum PaymentToken { export type ProposalGrantCategory = OldGrantCategory | NewGrantCategory export const VALID_CATEGORIES = [NewGrantCategory.CoreUnit, NewGrantCategory.Platform] -export const INVALID_CATEGORIES = Object.values(NewGrantCategory).filter((item) => !VALID_CATEGORIES.includes(item)) -export enum ProjectStatus { +export enum VestingStatus { Pending = 'Pending', InProgress = 'In Progress', Finished = 'Finished', @@ -70,6 +70,14 @@ export enum ProjectStatus { Revoked = 'Revoked', } +export enum ProjectStatus { + Pending = 'pending', + InProgress = 'in_progress', + Finished = 'finished', + Paused = 'paused', + Revoked = 'revoked', +} + export function isGrantSubtype(value: string | null | undefined) { return ( !!value && @@ -81,6 +89,24 @@ export function toGrantSubtype(value: string | null | undefined, orElse: return isGrantSubtype(value) ? (value as SubtypeOptions) : orElse() } +export const MilestoneItemSchema = { + title: { + type: 'string', + minLength: 1, + maxLength: 80, + }, + tasks: { + type: 'string', + minLength: 1, + maxLength: 750, + }, + delivery_date: { + type: 'string', + minLength: 1, + maxLength: 10, + }, +} + export const GrantRequestGeneralInfoSchema = { title: { type: 'string', @@ -92,7 +118,11 @@ export const GrantRequestGeneralInfoSchema = { minLength: 1, maxLength: 500, }, - description: { type: 'string', minLength: 20, maxLength: 3250 }, + description: { + type: 'string', + minLength: 20, + maxLength: 3250, + }, beneficiary: { type: 'string', format: 'address', @@ -104,7 +134,17 @@ export const GrantRequestGeneralInfoSchema = { roadmap: { type: 'string', minLength: 20, - maxLength: 2000, + maxLength: 1500, + }, + milestones: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: [...Object.keys(MilestoneItemSchema)], + properties: MilestoneItemSchema, + }, + maxItems: MILESTONE_SUBMIT_LIMIT, }, coAuthors: { type: 'array', @@ -308,6 +348,10 @@ export const TeamMemberItemSchema = { minLength: 1, maxLength: 750, }, + address: { + type: 'string', + format: 'address', + }, relevantLink: { type: 'string', minLength: 0, @@ -321,7 +365,7 @@ export const GrantRequestTeamSchema = { items: { type: 'object', additionalProperties: false, - required: [...Object.keys(TeamMemberItemSchema)], + required: [...Object.keys(TeamMemberItemSchema).filter((key) => key !== 'address')], properties: TeamMemberItemSchema, }, }, @@ -365,7 +409,7 @@ export type GrantRequest = { category: NewGrantCategory | null } & GrantRequestFunding & GrantRequestGeneralInfo & - GrantRequestTeam & + ProposalRequestTeam & GrantRequestCategoryAssessment & GrantRequestDueDiligence @@ -385,6 +429,7 @@ export type GrantRequestGeneralInfo = { specification?: string personnel?: string roadmap: string + milestones: Milestone[] coAuthors?: string[] } @@ -402,12 +447,19 @@ export type GrantRequestDueDiligence = { export type TeamMember = { name: string + address?: string | null role: string about: string relevantLink?: string } -export type GrantRequestTeam = { +export type Milestone = { + title: string + tasks: string + delivery_date: string +} + +export type ProposalRequestTeam = { members: TeamMember[] } diff --git a/src/entities/Project/types.ts b/src/entities/Project/types.ts new file mode 100644 index 000000000..40569c5cf --- /dev/null +++ b/src/entities/Project/types.ts @@ -0,0 +1,39 @@ +import isEthereumAddress from 'validator/lib/isEthereumAddress' +import { ZodSchema, z } from 'zod' + +import { PersonnelAttributes } from '../../back/models/Personnel' +import { ProjectLink } from '../../back/models/ProjectLink' +import { ProjectMilestone } from '../../back/models/ProjectMilestone' + +const addressCheck = (data: string) => !data || (!!data && isEthereumAddress(data)) + +export type PersonnelInCreation = Pick< + PersonnelAttributes, + 'name' | 'address' | 'role' | 'about' | 'relevantLink' | 'project_id' +> +export const PersonnelInCreationSchema: ZodSchema = z.object({ + name: z.string().min(1).max(80), + address: z.string().refine(addressCheck).optional().or(z.null()), + role: z.string().min(1).max(80), + about: z.string().min(1).max(750), + relevantLink: z.string().min(0).max(200).url().optional().or(z.literal('')), + project_id: z.string().min(0), +}) + +export type ProjectLinkInCreation = Pick +export const ProjectLinkInCreationSchema: ZodSchema = z.object({ + label: z.string().min(1).max(80), + url: z.string().min(0).max(200).url(), + project_id: z.string().min(0), +}) + +export type ProjectMilestoneInCreation = Pick< + ProjectMilestone, + 'title' | 'description' | 'delivery_date' | 'project_id' +> +export const ProjectMilestoneInCreationSchema: ZodSchema = z.object({ + title: z.string().min(1).max(80), + description: z.string().min(1).max(750), + delivery_date: z.date(), + project_id: z.string().min(0), +}) diff --git a/src/entities/Proposal/constants.ts b/src/entities/Proposal/constants.ts index 4489e61e7..4388b8bdb 100644 --- a/src/entities/Proposal/constants.ts +++ b/src/entities/Proposal/constants.ts @@ -18,3 +18,4 @@ export const SUBMISSION_THRESHOLD_TENDER = process.env.GATSBY_SUBMISSION_THRESHO export const SUBMISSION_THRESHOLD_HIRING = process.env.GATSBY_SUBMISSION_THRESHOLD_HIRING export const SUBMISSION_THRESHOLD_GRANT = process.env.GATSBY_SUBMISSION_THRESHOLD_GRANT export const VOTING_POWER_TO_PASS_HIRING = process.env.GATSBY_VOTING_POWER_TO_PASS_HIRING +export const MILESTONE_SUBMIT_LIMIT = 10 diff --git a/src/entities/Proposal/jobs.ts b/src/entities/Proposal/jobs.ts index b43b4809b..5144a2868 100644 --- a/src/entities/Proposal/jobs.ts +++ b/src/entities/Proposal/jobs.ts @@ -9,6 +9,7 @@ import BidService from '../../services/BidService' import { BudgetService } from '../../services/BudgetService' import { DiscourseService } from '../../services/DiscourseService' import { ErrorService } from '../../services/ErrorService' +import { ProjectService } from '../../services/ProjectService' import { ProposalService } from '../../services/ProposalService' import { ErrorCategory } from '../../utils/errorCategories' import { isProdEnv } from '../../utils/governanceEnvs' @@ -182,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() @@ -201,6 +204,7 @@ export async function finishProposal() { await updateProposalsAndBudgets(proposalsWithOutcome, budgetsWithUpdates) + await ProjectService.createProjects(proposalsWithOutcome) NotificationService.sendFinishProposalNotifications(proposalsWithOutcome) BadgesService.giveFinishProposalBadges(proposalsWithOutcome) DiscourseService.commentFinishedProposals(proposalsWithOutcome) diff --git a/src/entities/Proposal/model.ts b/src/entities/Proposal/model.ts index 9ec76e2db..5f1ee4fa1 100644 --- a/src/entities/Proposal/model.ts +++ b/src/entities/Proposal/model.ts @@ -15,6 +15,7 @@ import { toLower } from 'lodash' import isEthereumAddress from 'validator/lib/isEthereumAddress' import isUUID from 'validator/lib/isUUID' +import ProjectModel from '../../back/models/Project' import Time from '../../utils/date/Time' import { UnpublishedBidStatus } from '../Bid/types' import CoauthorModel from '../Coauthor/model' @@ -31,6 +32,7 @@ import { ProposalListFilter, ProposalStatus, ProposalType, + ProposalWithProject, SortingOrder, isProposalType, } from './types' @@ -69,6 +71,28 @@ export default class ProposalModel extends Model { } } + static async getProposalWithProject(id: string): Promise { + if (!isUUID(id || '')) { + throw new Error(`Not found proposal: "${id}"`) + } + + const query = SQL` + SELECT p.*, pr.id as "project_id", pr.status as "project_status" + FROM ${table(ProposalModel)} p + LEFT JOIN ${table(ProjectModel)} pr ON p.id = pr.proposal_id + WHERE p.id = ${id} AND p.deleted = false + ` + + const result = await this.namedQuery('get_proposal_with_project', query) + if (!result.length) { + throw new Error(`Not found proposal: "${id}"`) + } + + return { + ...this.parse(result[0]), + } + } + static create(proposal: U): Promise { const keys = Object.keys(proposal).map((key) => key.replace(/\W/gi, '')) @@ -429,18 +453,19 @@ export default class ProposalModel extends Model { return proposals.map(this.parse) } - static async getProjectList(): Promise { + static async getProjectList(): Promise { const status = [ProposalStatus.Passed, ProposalStatus.Enacted].map((status) => SQL`${status}`) const types = [ProposalType.Bid, ProposalType.Grant].map((type) => SQL`${type}`) const proposals = await this.namedQuery( 'get_project_list', SQL` - SELECT * - FROM ${table(ProposalModel)} + SELECT prop.*, proj.id as project_id + FROM ${table(ProposalModel)} prop + LEFT OUTER JOIN ${table(ProjectModel)} proj on prop.id = proj.proposal_id WHERE "deleted" = FALSE - AND "type" IN (${join(types)}) - AND "status" IN (${join(status)}) + AND prop."type" IN (${join(types)}) + AND prop."status" IN (${join(status)}) ORDER BY "created_at" DESC ` ) @@ -464,7 +489,7 @@ export default class ProposalModel extends Model { } } - 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( diff --git a/src/entities/Proposal/templates/bid.ts b/src/entities/Proposal/templates/bid.ts index b18d1f313..cb30e22c7 100644 --- a/src/entities/Proposal/templates/bid.ts +++ b/src/entities/Proposal/templates/bid.ts @@ -43,7 +43,11 @@ ${proposal.email} ${formatMarkdown(proposal.deliverables)} -## Roadmap and milestones +## Roadmap ${formatMarkdown(proposal.roadmap)} + +## Milestones + +${formatMarkdown(proposal.milestones.map((milestone) => `${milestone.delivery_date} - ${milestone.title}`).join('\n'))} ` diff --git a/src/entities/Proposal/templates/grant.ts b/src/entities/Proposal/templates/grant.ts index 95828f085..0eeb0a359 100644 --- a/src/entities/Proposal/templates/grant.ts +++ b/src/entities/Proposal/templates/grant.ts @@ -39,7 +39,11 @@ ${proposal.email} ${formatMarkdown(proposal.description)} -## Roadmap and milestones +## Roadmap ${formatMarkdown(proposal.roadmap)} + +## Milestones + +${formatMarkdown(proposal.milestones.map((milestone) => `${milestone.delivery_date} - ${milestone.title}`).join('\n'))} ` diff --git a/src/entities/Proposal/types.ts b/src/entities/Proposal/types.ts index b3ff25c9f..1096cfa33 100644 --- a/src/entities/Proposal/types.ts +++ b/src/entities/Proposal/types.ts @@ -5,16 +5,17 @@ import { SQLStatement } from 'decentraland-gatsby/dist/entities/Database/utils' import { SnapshotProposal } from '../../clients/SnapshotTypes' import { CommitteeName } from '../../clients/Transparency' +import { Vesting } from '../../clients/VestingData' import { UnpublishedBidInfo } from '../Bid/types' import { CategoryAssessmentQuestions, GrantRequestDueDiligence, GrantRequestGeneralInfo, - GrantRequestTeam, GrantTierType, PaymentToken, ProjectStatus, ProposalGrantCategory, + ProposalRequestTeam, SubtypeOptions, VestingStartDate, } from '../Grant/types' @@ -70,6 +71,11 @@ export type ProposalAttributes = any> = { textsearch: SQLStatement | string | null | undefined } +export interface ProposalWithProject extends ProposalAttributes { + project_id?: string | null + project_status?: ProjectStatus | null +} + export type ProposalListFilter = { user: string type: ProposalType @@ -203,12 +209,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'], @@ -675,7 +681,7 @@ export const ProposalRequiredVP = { export type GrantProposalConfiguration = GrantRequestGeneralInfo & GrantRequestDueDiligence & - GrantRequestTeam & { + ProposalRequestTeam & { category: ProposalGrantCategory | null size: number paymentToken?: PaymentToken @@ -804,36 +810,37 @@ export type ProposalCommentsInDiscourse = { comments: ProposalComment[] } -export type VestingContractData = { - vestedAmount: number - releasable: number - released: number - start_at: number - finish_at: number - vesting_total_amount: number +export type OneTimePayment = { + enacting_tx: string + token?: string + tx_amount?: number +} + +export type ProjectFunding = { + enacted_at?: string + one_time_payment?: OneTimePayment + vesting?: Vesting } -export type Project = { +export type ProposalProject = { id: string + project_id?: string | null + status: ProjectStatus title: string user: string size: number type: ProposalType + about: string created_at: number + updated_at: number configuration: { category: ProposalGrantCategory tier: string } - status?: ProjectStatus - contract?: VestingContractData - enacting_tx?: string - token?: string - enacted_at?: number - tx_amount?: number - tx_date?: number + funding?: ProjectFunding } -export type ProjectWithUpdate = Project & { +export type ProposalProjectWithUpdate = ProposalProject & { update?: IndexedUpdate | null update_timestamp?: number } diff --git a/src/entities/Proposal/utils.test.ts b/src/entities/Proposal/utils.test.ts index adb80abbc..5c5c5958f 100644 --- a/src/entities/Proposal/utils.test.ts +++ b/src/entities/Proposal/utils.test.ts @@ -4,7 +4,7 @@ import { isProposalDeletable, isProposalEnactable, isProposalStatus, - isValidUpdateProposalStatus, + isValidProposalStatusUpdate, proposalCanBePassedOrRejected, toProposalStatus, } from './utils' @@ -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) }) }) }) diff --git a/src/entities/Proposal/utils.ts b/src/entities/Proposal/utils.ts index b86f27884..df6487932 100644 --- a/src/entities/Proposal/utils.ts +++ b/src/entities/Proposal/utils.ts @@ -7,7 +7,7 @@ import 'isomorphic-fetch' import numeral from 'numeral' import { Governance } from '../../clients/Governance' -import { GOVERNANCE_API, GOVERNANCE_URL } from '../../constants' +import { GOVERNANCE_URL } from '../../constants' import { getEnumDisplayName } from '../../helpers' import { getTile } from '../../utils/Land' import Time from '../../utils/date/Time' @@ -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 ( @@ -151,7 +151,7 @@ export function governanceUrl(pathname = '') { export function proposalUrl(id: ProposalAttributes['id']) { const params = new URLSearchParams({ id }) - const target = new URL(GOVERNANCE_API) + const target = new URL(GOVERNANCE_URL) target.pathname = '/proposal/' target.search = '?' + params.toString() return target.toString() diff --git a/src/entities/Transparency/types.ts b/src/entities/Transparency/types.ts index 53128d581..41fc6175a 100644 --- a/src/entities/Transparency/types.ts +++ b/src/entities/Transparency/types.ts @@ -9,19 +9,3 @@ export type TokenInWallet = { timestamp: Date rate: number } - -export type TokenTotal = { - symbol: string - amount: bigint - quote: bigint -} - -export type AggregatedTokenBalance = { - tokenTotal: TokenTotal - tokenInWallets: TokenInWallet[] -} - -export type BlockExplorerLink = { - link: string - name: string -} diff --git a/src/entities/Transparency/utils.ts b/src/entities/Transparency/utils.ts index a8a5160ee..ef9d6909b 100644 --- a/src/entities/Transparency/utils.ts +++ b/src/entities/Transparency/utils.ts @@ -1,60 +1,3 @@ -import logger from '../../utils/logger' - -import { AggregatedTokenBalance, BlockExplorerLink, TokenInWallet } from './types' - -function alphabeticalSort(a: string, b: string) { - return ('' + a).localeCompare(b) -} - -function balanceSort(a: bigint, b: bigint) { - if (a > b) return -1 - if (a < b) return 1 - return 0 -} - -export function aggregateBalances(latestBalances: TokenInWallet[]): AggregatedTokenBalance[] { - const tokenBalances: AggregatedTokenBalance[] = [] - latestBalances.map((balance) => { - const tokenBalance = tokenBalances.find((tokenBalance) => tokenBalance.tokenTotal.symbol == balance.symbol) - if (!tokenBalance) { - tokenBalances.push({ - tokenTotal: { - symbol: balance.symbol, - amount: balance.amount, - quote: balance.quote, - }, - tokenInWallets: [balance], - }) - } else { - tokenBalance.tokenTotal.amount = tokenBalance.tokenTotal.amount + balance.amount - tokenBalance.tokenTotal.quote = tokenBalance.tokenTotal.quote + balance.quote - tokenBalance.tokenInWallets.push(balance) - } - }) - - for (const tokenBalance of tokenBalances) { - tokenBalance.tokenInWallets.sort((a, b) => alphabeticalSort(a.name, b.name)) - } - tokenBalances.sort((a, b) => balanceSort(a.tokenTotal.amount, b.tokenTotal.amount)) - return tokenBalances -} - -const ETHERSCAN_BASE_URL = 'https://etherscan.io/' -export const POLYGONSCAN_BASE_URL = 'https://polygonscan.com/' - -export function blockExplorerLink(wallet: TokenInWallet): BlockExplorerLink { - const addressUrl = 'address/' + wallet.address - switch (wallet.network) { - case 'Ethereum': - return { name: 'Etherscan', link: ETHERSCAN_BASE_URL + addressUrl } - case 'Polygon': - return { name: 'Polygonscan', link: POLYGONSCAN_BASE_URL + addressUrl } - default: - logger.error('Unable to get block explorer link', { wallet }) - return { name: '', link: '/' } - } -} - export function validateUniqueAddresses(addresses: string[]): boolean { const uniqueSet = new Set(addresses.map((address) => address.toLowerCase())) return uniqueSet.size === addresses.length diff --git a/src/entities/Updates/model.test.ts b/src/entities/Updates/model.test.ts index fc0860e2e..063f05ee9 100644 --- a/src/entities/Updates/model.test.ts +++ b/src/entities/Updates/model.test.ts @@ -1,14 +1,38 @@ import crypto from 'crypto' -import { VestingInfo } from '../../clients/VestingData' +import { Project } from '../../back/models/Project' +import { UpdateService } from '../../back/services/update' +import * as VestingUtils from '../../clients/VestingData' +import { VestingWithLogs } from '../../clients/VestingData' +import { ProjectService } from '../../services/ProjectService' import Time from '../../utils/date/Time' import { getMonthsBetweenDates } from '../../utils/date/getMonthsBetweenDates' +import { ProjectStatus } from '../Grant/types' import UpdateModel from './model' import { UpdateStatus } from './types' -const PROPOSAL_ID = '123' const UUID = '00000000-0000-0000-0000-000000000000' +const PROPOSAL_ID = '00000000-0000-0000-0000-000000000001' +const PROJECT_ID = '00000000-0000-0000-0000-000000000002' + +const MOCK_PROJECT: Project = { + id: PROJECT_ID, + proposal_id: PROPOSAL_ID, + title: '', + status: ProjectStatus.Pending, + created_at: new Date(), + vesting_addresses: [], + personnel: [], + links: [], + milestones: [], + author: '', + coauthors: null, +} + +function mockVestingData(vestingDates: VestingWithLogs) { + jest.spyOn(VestingUtils, 'getVestingWithLogs').mockResolvedValue(vestingDates) +} describe('UpdateModel', () => { const FAKE_NOW = Time.utc('2020-01-01 00:00:00z').toDate() @@ -22,34 +46,39 @@ describe('UpdateModel', () => { jest.spyOn(crypto, 'randomUUID').mockReturnValue(UUID) jest.useFakeTimers() jest.setSystemTime(FAKE_NOW) + jest.spyOn(ProjectService, 'getUpdatedProject').mockResolvedValue(MOCK_PROJECT) }) describe('createPendingUpdates', () => { describe('for a vesting with a duration of almost 3 months', () => { describe('when vesting start date is on the 1st of the month', () => { const vestingDates = { - vestingStartAt: '2020-01-01 00:00:00z', - vestingFinishAt: '2020-03-31 00:00:00z', - } as VestingInfo + start_at: '2020-01-01 00:00:00z', + finish_at: '2020-03-31 00:00:00z', + } as VestingWithLogs it('calculates the correct amount of pending updates', () => { - expect( - getMonthsBetweenDates(new Date(vestingDates.vestingStartAt), new Date(vestingDates.vestingFinishAt)) - ).toEqual({ months: 2, extraDays: 30 }) - expect(UpdateModel.getAmountOfUpdates(vestingDates)).toEqual(3) + expect(getMonthsBetweenDates(new Date(vestingDates.start_at), new Date(vestingDates.finish_at))).toEqual({ + months: 2, + extraDays: 30, + }) + expect(UpdateService.getAmountOfUpdates(vestingDates)).toEqual(3) }) it('deletes any pending updates for the proposal', async () => { - await UpdateModel.createPendingUpdates(PROPOSAL_ID, vestingDates) - expect(UpdateModel.delete).toHaveBeenCalledWith({ proposal_id: PROPOSAL_ID, status: UpdateStatus.Pending }) + mockVestingData(vestingDates) + await UpdateService.createPendingUpdatesForVesting(PROJECT_ID) + expect(UpdateModel.delete).toHaveBeenCalledWith({ project_id: PROJECT_ID, status: UpdateStatus.Pending }) }) it('creates expected pending updates with the correct attributes', async () => { - await UpdateModel.createPendingUpdates(PROPOSAL_ID, vestingDates) + mockVestingData(vestingDates) + await UpdateService.createPendingUpdatesForVesting(PROJECT_ID) expect(UpdateModel.createMany).toHaveBeenCalledWith([ { id: UUID, proposal_id: PROPOSAL_ID, + project_id: PROJECT_ID, status: UpdateStatus.Pending, due_date: Time.utc('2020-02-01T00:00:00.000Z').toDate(), created_at: FAKE_NOW, @@ -58,6 +87,7 @@ describe('UpdateModel', () => { { id: UUID, proposal_id: PROPOSAL_ID, + project_id: PROJECT_ID, status: UpdateStatus.Pending, due_date: Time.utc('2020-03-01T00:00:00.000Z').toDate(), created_at: FAKE_NOW, @@ -66,6 +96,7 @@ describe('UpdateModel', () => { { id: UUID, proposal_id: PROPOSAL_ID, + project_id: PROJECT_ID, status: UpdateStatus.Pending, due_date: Time.utc('2020-04-01T00:00:00.000Z').toDate(), created_at: FAKE_NOW, @@ -77,28 +108,31 @@ describe('UpdateModel', () => { describe('when vesting start date is on the 15st of the month', () => { const vestingDates = { - vestingStartAt: '2020-01-15 00:00:00z', - vestingFinishAt: '2020-04-14 00:00:00z', - } as VestingInfo + start_at: '2020-01-15 00:00:00z', + finish_at: '2020-04-14 00:00:00z', + } as VestingWithLogs it('calculates the correct amount of pending updates', () => { - expect( - getMonthsBetweenDates(new Date(vestingDates.vestingStartAt), new Date(vestingDates.vestingFinishAt)) - ).toEqual({ months: 2, extraDays: 30 }) - expect(UpdateModel.getAmountOfUpdates(vestingDates)).toEqual(3) + expect(getMonthsBetweenDates(new Date(vestingDates.start_at), new Date(vestingDates.finish_at))).toEqual({ + months: 2, + extraDays: 30, + }) + expect(UpdateService.getAmountOfUpdates(vestingDates)).toEqual(3) }) it('deletes any pending updates for the proposal', async () => { - await UpdateModel.createPendingUpdates(PROPOSAL_ID, vestingDates) - expect(UpdateModel.delete).toHaveBeenCalledWith({ proposal_id: PROPOSAL_ID, status: UpdateStatus.Pending }) + await UpdateService.createPendingUpdatesForVesting(PROJECT_ID) + expect(UpdateModel.delete).toHaveBeenCalledWith({ project_id: PROJECT_ID, status: UpdateStatus.Pending }) }) it('creates expected pending updates with the correct attributes', async () => { - await UpdateModel.createPendingUpdates(PROPOSAL_ID, vestingDates) + mockVestingData(vestingDates) + await UpdateService.createPendingUpdatesForVesting(PROJECT_ID) expect(UpdateModel.createMany).toHaveBeenCalledWith([ { id: UUID, proposal_id: PROPOSAL_ID, + project_id: PROJECT_ID, status: UpdateStatus.Pending, due_date: Time.utc('2020-02-15T00:00:00.000Z').toDate(), created_at: FAKE_NOW, @@ -107,6 +141,7 @@ describe('UpdateModel', () => { { id: UUID, proposal_id: PROPOSAL_ID, + project_id: PROJECT_ID, status: UpdateStatus.Pending, due_date: Time.utc('2020-03-15T00:00:00.000Z').toDate(), created_at: FAKE_NOW, @@ -115,6 +150,7 @@ describe('UpdateModel', () => { { id: UUID, proposal_id: PROPOSAL_ID, + project_id: PROJECT_ID, status: UpdateStatus.Pending, due_date: Time.utc('2020-04-15T00:00:00.000Z').toDate(), created_at: FAKE_NOW, @@ -127,24 +163,27 @@ describe('UpdateModel', () => { describe('for a vesting with a duration of 6 months and some extra days, with a starting date different than the preferred', () => { const vestingDates = { - vestingStartAt: '2020-11-15 00:00:00z', - vestingFinishAt: '2021-05-31 00:00:00z', - } as VestingInfo + start_at: '2020-11-15 00:00:00z', + finish_at: '2021-05-31 00:00:00z', + } as VestingWithLogs describe('when vesting start date is on the 15th of the month', () => { it('calculates the correct amount of pending updates', () => { - expect( - getMonthsBetweenDates(new Date(vestingDates.vestingStartAt), new Date(vestingDates.vestingFinishAt)) - ).toEqual({ months: 6, extraDays: 16 }) - expect(UpdateModel.getAmountOfUpdates(vestingDates)).toEqual(7) + expect(getMonthsBetweenDates(new Date(vestingDates.start_at), new Date(vestingDates.finish_at))).toEqual({ + months: 6, + extraDays: 16, + }) + expect(UpdateService.getAmountOfUpdates(vestingDates)).toEqual(7) }) it('creates expected pending updates with the correct attributes', async () => { - await UpdateModel.createPendingUpdates(PROPOSAL_ID, vestingDates) + mockVestingData(vestingDates) + await UpdateService.createPendingUpdatesForVesting(PROJECT_ID) expect(UpdateModel.createMany).toHaveBeenCalledWith([ { id: UUID, proposal_id: PROPOSAL_ID, + project_id: PROJECT_ID, status: UpdateStatus.Pending, due_date: Time.utc('2020-12-15T00:00:00.000Z').toDate(), created_at: FAKE_NOW, @@ -153,6 +192,7 @@ describe('UpdateModel', () => { { id: UUID, proposal_id: PROPOSAL_ID, + project_id: PROJECT_ID, status: UpdateStatus.Pending, due_date: Time.utc('2021-01-15T00:00:00.000Z').toDate(), created_at: FAKE_NOW, @@ -161,6 +201,7 @@ describe('UpdateModel', () => { { id: UUID, proposal_id: PROPOSAL_ID, + project_id: PROJECT_ID, status: UpdateStatus.Pending, due_date: Time.utc('2021-02-15T00:00:00.000Z').toDate(), created_at: FAKE_NOW, @@ -169,6 +210,7 @@ describe('UpdateModel', () => { { id: UUID, proposal_id: PROPOSAL_ID, + project_id: PROJECT_ID, status: UpdateStatus.Pending, due_date: Time.utc('2021-03-15T00:00:00.000Z').toDate(), created_at: FAKE_NOW, @@ -177,6 +219,7 @@ describe('UpdateModel', () => { { id: UUID, proposal_id: PROPOSAL_ID, + project_id: PROJECT_ID, status: UpdateStatus.Pending, due_date: Time.utc('2021-04-15T00:00:00.000Z').toDate(), created_at: FAKE_NOW, @@ -185,6 +228,7 @@ describe('UpdateModel', () => { { id: UUID, proposal_id: PROPOSAL_ID, + project_id: PROJECT_ID, status: UpdateStatus.Pending, due_date: Time.utc('2021-05-15T00:00:00.000Z').toDate(), created_at: FAKE_NOW, @@ -193,6 +237,7 @@ describe('UpdateModel', () => { { id: UUID, proposal_id: PROPOSAL_ID, + project_id: PROJECT_ID, status: UpdateStatus.Pending, due_date: Time.utc('2021-06-15T00:00:00.000Z').toDate(), created_at: FAKE_NOW, @@ -218,24 +263,27 @@ describe('UpdateModel', () => { describe('for a vesting with a duration of exactly 6 months', () => { const vestingDates = { - vestingStartAt: '2020-07-01 00:00:00z', - vestingFinishAt: '2021-01-01 00:00:00z', - } as VestingInfo + start_at: '2020-07-01 00:00:00z', + finish_at: '2021-01-01 00:00:00z', + } as VestingWithLogs describe('when the vesting contract start date is the first day of the month', () => { it('calculates the correct amount of pending updates', () => { - expect( - getMonthsBetweenDates(new Date(vestingDates.vestingStartAt), new Date(vestingDates.vestingFinishAt)) - ).toEqual({ months: 6, extraDays: 0 }) - expect(UpdateModel.getAmountOfUpdates(vestingDates)).toEqual(6) + expect(getMonthsBetweenDates(new Date(vestingDates.start_at), new Date(vestingDates.finish_at))).toEqual({ + months: 6, + extraDays: 0, + }) + expect(UpdateService.getAmountOfUpdates(vestingDates)).toEqual(6) }) it('creates expected pending updates with the correct attributes', async () => { - await UpdateModel.createPendingUpdates(PROPOSAL_ID, vestingDates) + mockVestingData(vestingDates) + await UpdateService.createPendingUpdatesForVesting(PROJECT_ID) expect(UpdateModel.createMany).toHaveBeenCalledWith([ { id: UUID, proposal_id: PROPOSAL_ID, + project_id: PROJECT_ID, status: UpdateStatus.Pending, due_date: Time.utc('2020-08-01T00:00:00.000Z').toDate(), created_at: FAKE_NOW, @@ -244,6 +292,7 @@ describe('UpdateModel', () => { { id: UUID, proposal_id: PROPOSAL_ID, + project_id: PROJECT_ID, status: UpdateStatus.Pending, due_date: Time.utc('2020-09-01T00:00:00.000Z').toDate(), created_at: FAKE_NOW, @@ -252,6 +301,7 @@ describe('UpdateModel', () => { { id: UUID, proposal_id: PROPOSAL_ID, + project_id: PROJECT_ID, status: UpdateStatus.Pending, due_date: Time.utc('2020-10-01T00:00:00.000Z').toDate(), created_at: FAKE_NOW, @@ -260,6 +310,7 @@ describe('UpdateModel', () => { { id: UUID, proposal_id: PROPOSAL_ID, + project_id: PROJECT_ID, status: UpdateStatus.Pending, due_date: Time.utc('2020-11-01T00:00:00.000Z').toDate(), created_at: FAKE_NOW, @@ -268,6 +319,7 @@ describe('UpdateModel', () => { { id: UUID, proposal_id: PROPOSAL_ID, + project_id: PROJECT_ID, status: UpdateStatus.Pending, due_date: Time.utc('2020-12-01T00:00:00.000Z').toDate(), created_at: FAKE_NOW, @@ -276,6 +328,7 @@ describe('UpdateModel', () => { { id: UUID, proposal_id: PROPOSAL_ID, + project_id: PROJECT_ID, status: UpdateStatus.Pending, due_date: Time.utc('2021-01-01T00:00:00.000Z').toDate(), created_at: FAKE_NOW, @@ -289,19 +342,19 @@ describe('UpdateModel', () => { describe('getDueDate', () => { it('returns the same date for following month plus the index', () => { - expect(UpdateModel.getDueDate(Time.utc('2020-11-01 00:00:00.000Z'), 0)).toEqual( + expect(UpdateService.getDueDate(Time.utc('2020-11-01 00:00:00.000Z'), 0)).toEqual( new Date('2020-12-01 00:00:00.000Z') ) - expect(UpdateModel.getDueDate(Time.utc('2020-11-15 00:00:00.000Z'), 0)).toEqual( + expect(UpdateService.getDueDate(Time.utc('2020-11-15 00:00:00.000Z'), 0)).toEqual( new Date('2020-12-15 00:00:00.000Z') ) - expect(UpdateModel.getDueDate(Time.utc('2020-11-15 00:00:00.000Z'), 1)).toEqual( + expect(UpdateService.getDueDate(Time.utc('2020-11-15 00:00:00.000Z'), 1)).toEqual( new Date('2021-01-15 00:00:00.000Z') ) - expect(UpdateModel.getDueDate(Time.utc('2020-11-01 00:00:00.000Z'), 1)).toEqual( + expect(UpdateService.getDueDate(Time.utc('2020-11-01 00:00:00.000Z'), 1)).toEqual( new Date('2021-01-01 00:00:00.000Z') ) - expect(UpdateModel.getDueDate(Time.utc('2020-11-15 00:00:00.000Z'), 2)).toEqual( + expect(UpdateService.getDueDate(Time.utc('2020-11-15 00:00:00.000Z'), 2)).toEqual( new Date('2021-02-15 00:00:00.000Z') ) }) diff --git a/src/entities/Updates/model.ts b/src/entities/Updates/model.ts index e7b35d245..b09d5203c 100644 --- a/src/entities/Updates/model.ts +++ b/src/entities/Updates/model.ts @@ -1,53 +1,16 @@ import crypto from 'crypto' import { Model } from 'decentraland-gatsby/dist/entities/Database/model' +import { SQL, table } from 'decentraland-gatsby/dist/entities/Database/utils' -import { type VestingInfo } from '../../clients/VestingData' -import Time from '../../utils/date/Time' -import { getMonthsBetweenDates } from '../../utils/date/getMonthsBetweenDates' +import ProjectModel from '../../back/models/Project' import { UpdateAttributes, UpdateStatus } from './types' export default class UpdateModel extends Model { - static tableName = 'proposal_updates' + static tableName = 'project_updates' static withTimestamps = false static primaryKey = 'id' - static async createPendingUpdates(proposalId: string, vestingContractData: VestingInfo) { - if (proposalId.length < 0) throw new Error('Unable to create updates for empty proposal id') - - const now = new Date() - const updatesQuantity = this.getAmountOfUpdates(vestingContractData) - const firstUpdateStartingDate = Time.utc(vestingContractData.vestingStartAt).startOf('day') - - await UpdateModel.delete({ proposal_id: proposalId, status: UpdateStatus.Pending }) - - const updates = Array.from(Array(updatesQuantity), (_, index) => { - const update: UpdateAttributes = { - id: crypto.randomUUID(), - proposal_id: proposalId, - status: UpdateStatus.Pending, - due_date: this.getDueDate(firstUpdateStartingDate, index), - created_at: now, - updated_at: now, - } - - return update - }) - return await this.createMany(updates) - } - - public static getAmountOfUpdates(vestingDates: VestingInfo) { - const exactDuration = getMonthsBetweenDates( - new Date(vestingDates.vestingStartAt), - new Date(vestingDates.vestingFinishAt) - ) - return exactDuration.months + (exactDuration.extraDays > 0 ? 1 : 0) - } - - public static getDueDate(startingDate: Time.Dayjs, index: number) { - return startingDate.add(1 + index, 'months').toDate() - } - static async createUpdate( update: Omit ): Promise { @@ -63,4 +26,15 @@ export default class UpdateModel extends Model { ...update, }) } + + static async setProjectIds() { + const query = SQL` + UPDATE ${table(this)} pu + SET project_id = p.id + FROM ${table(ProjectModel)} p + WHERE pu.proposal_id = p.proposal_id + ` + + return await this.namedQuery('set_project_ids', query) + } } diff --git a/src/entities/Updates/types.ts b/src/entities/Updates/types.ts index 3f39d1e34..c09d4f44c 100644 --- a/src/entities/Updates/types.ts +++ b/src/entities/Updates/types.ts @@ -23,6 +23,7 @@ export type UpdateAttributes = Partial & Partial & { id: string proposal_id: string + project_id: string author?: string status: UpdateStatus due_date?: Date diff --git a/src/entities/Updates/utils.test.ts b/src/entities/Updates/utils.test.ts index f0ad4eea6..e5c63fda3 100644 --- a/src/entities/Updates/utils.test.ts +++ b/src/entities/Updates/utils.test.ts @@ -22,6 +22,7 @@ type GenerateUpdate = Pick< const generateUpdate = (update: GenerateUpdate): UpdateAttributes => ({ id: crypto.randomUUID(), proposal_id: crypto.randomUUID(), + project_id: crypto.randomUUID(), health: update.health, introduction: update.introduction, highlights: update.highlights, diff --git a/src/entities/Updates/utils.ts b/src/entities/Updates/utils.ts index fd4eadb93..070a7763c 100644 --- a/src/entities/Updates/utils.ts +++ b/src/entities/Updates/utils.ts @@ -1,22 +1,17 @@ import sum from 'lodash/sum' -import { VestingInfo, VestingLog } from '../../clients/VestingData' +import { VestingLog, VestingWithLogs } from '../../clients/VestingData' import { GOVERNANCE_API } from '../../constants' import { ContractVersion, TopicsByVersion } from '../../utils/contracts/vesting' import Time from '../../utils/date/Time' -import { ProposalStatus } from '../Proposal/types' -import { FinancialRecord, UpdateAttributes, UpdateStatus } from './types' +import { UpdateAttributes, UpdateStatus } from './types' const TOPICS_V1 = TopicsByVersion[ContractVersion.V1] const TOPICS_V2 = TopicsByVersion[ContractVersion.V2] const RELEASE_TOPICS = new Set([TOPICS_V1.RELEASE, TOPICS_V2.RELEASE]) -export function isProposalStatusWithUpdates(proposalStatus?: ProposalStatus) { - return proposalStatus === ProposalStatus.Enacted -} - export const getPublicUpdates = (updates: UpdateAttributes[]): UpdateAttributes[] => { const now = new Date() @@ -121,7 +116,7 @@ export function getFundsReleasedSinceLatestUpdate( } } -export function getReleases(vestings: VestingInfo[]) { +export function getReleases(vestings: VestingWithLogs[]) { return vestings .flatMap(({ logs }) => logs) .filter(({ topic }) => RELEASE_TOPICS.has(topic)) @@ -141,12 +136,3 @@ export function getLatestUpdate(publicUpdates: UpdateAttributes[], beforeDate?: return filteredUpdates.find((update) => Time(update.completion_date).isBefore(beforeDate)) } - -export function getDisclosedAndUndisclosedFunds( - releasedForThisUpdate: number, - financialRecords?: FinancialRecord[] | null -) { - const disclosedFunds = financialRecords?.reduce((acc, financialRecord) => acc + financialRecord.amount, 0) || 0 - const undisclosedFunds = disclosedFunds <= releasedForThisUpdate ? releasedForThisUpdate - disclosedFunds : 0 - return { disclosedFunds, undisclosedFunds } -} diff --git a/src/migrations/1707416989780_events-update-commented-type.ts b/src/migrations/1707416989780_events-update-commented-type.ts index 457443012..121b257d4 100644 --- a/src/migrations/1707416989780_events-update-commented-type.ts +++ b/src/migrations/1707416989780_events-update-commented-type.ts @@ -5,7 +5,8 @@ import { resetEventType } from "./1706037450493_events-delegation-types" export const shorthands: ColumnDefinitions | undefined = undefined export async function up(pgm: MigrationBuilder): Promise { - pgm.renameTypeValue('event_type', 'commented', 'proposal_commented') + // Migration run in production + // pgm.renameTypeValue('event_type', 'commented', 'proposal_commented') pgm.addTypeValue('event_type', 'project_update_commented', { ifNotExists: true }) } diff --git a/src/migrations/1715012697856_create-projects-table.ts b/src/migrations/1715012697856_create-projects-table.ts new file mode 100644 index 000000000..d56c95e5e --- /dev/null +++ b/src/migrations/1715012697856_create-projects-table.ts @@ -0,0 +1,60 @@ +import { MigrationBuilder } from 'node-pg-migrate' + +import Model from '../back/models/Project' +import { ProjectStatus } from "../entities/Grant/types" +import ProposalModel from "../entities/Proposal/model" + +const STATUS_TYPE = 'project_status_type' + +export async function up(pgm: MigrationBuilder): Promise { + pgm.createType(STATUS_TYPE, Object.values(ProjectStatus)) + pgm.createTable(Model.tableName, { + id: { + type: 'TEXT', + primaryKey: true, + notNull: true, + }, + proposal_id: { + type: 'TEXT', + notNull: true + }, + title: { + type: 'TEXT', + notNull: true + }, + status: { + type: STATUS_TYPE, + notNull: true, + }, + links: { + type: 'TEXT[]', + notNull: true, + default: '{}' + }, + about: { + type: 'TEXT', + }, + about_updated_by: { + type: 'TEXT', + }, + about_updated_at: { + type: 'TIMESTAMPTZ', + }, + updated_at: { + type: 'TIMESTAMPTZ', + }, + created_at: { + type: 'TIMESTAMPTZ', + notNull: true, + default: pgm.func('CURRENT_TIMESTAMP') + }, + }) + + pgm.createIndex(Model.tableName, 'proposal_id') + pgm.addConstraint(Model.tableName, 'proposal_id_fk', `FOREIGN KEY(proposal_id) REFERENCES ${ProposalModel.tableName}(id)`) +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.dropTable(Model.tableName) + pgm.dropType(STATUS_TYPE, { cascade: true }) +} diff --git a/src/migrations/1715019261618_create-personnel-table.ts b/src/migrations/1715019261618_create-personnel-table.ts new file mode 100644 index 000000000..4b35de831 --- /dev/null +++ b/src/migrations/1715019261618_create-personnel-table.ts @@ -0,0 +1,61 @@ +import { MigrationBuilder } from "node-pg-migrate" + +import Model from "../back/models/Personnel" +import ProjectModel from "../back/models/Project" + +export async function up(pgm: MigrationBuilder): Promise { + pgm.createTable(Model.tableName, { + id: { + type: 'TEXT', + primaryKey: true, + notNull: true, + }, + project_id: { + type: 'TEXT', + notNull: true + }, + address: { + type: 'TEXT', + }, + name: { + type: 'TEXT', + notNull: true + }, + role: { + type: 'TEXT', + notNull: true + }, + about: { + type: 'TEXT', + notNull: true + }, + relevantLink: { + type: 'TEXT', + }, + deleted: { + type: 'BOOLEAN', + notNull: true, + default: false + }, + updated_at: { + type: 'TIMESTAMPTZ', + }, + updated_by: { + type: 'TEXT', + }, + created_at: { + type: 'TIMESTAMPTZ', + notNull: true, + default: pgm.func('CURRENT_TIMESTAMP') + }, + }) + + pgm.createIndex(Model.tableName, 'project_id') + pgm.addConstraint(Model.tableName, 'address_check', 'CHECK(address ~* \'^(0x)?[0-9a-f]{40}$\')') + pgm.addConstraint(Model.tableName, 'project_id_fk', `FOREIGN KEY(project_id) REFERENCES ${ProjectModel.tableName}(id)`) +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.dropTable(Model.tableName) + pgm.dropType('personnel_status_type', { cascade: true }) +} diff --git a/src/migrations/1715085519126_create-project-milestones-table.ts b/src/migrations/1715085519126_create-project-milestones-table.ts new file mode 100644 index 000000000..7d62adac1 --- /dev/null +++ b/src/migrations/1715085519126_create-project-milestones-table.ts @@ -0,0 +1,56 @@ +import { MigrationBuilder } from "node-pg-migrate" +import ProjectModel from "../back/models/Project" +import Model, { ProjectMilestoneStatus } from "../back/models/ProjectMilestone" + +const STATUS_TYPE = 'project_milestone_status_type' + + +export async function up(pgm: MigrationBuilder): Promise { + pgm.createType(STATUS_TYPE, Object.values(ProjectMilestoneStatus)) + pgm.createTable(Model.tableName, { + id: { + type: 'TEXT', + primaryKey: true, + notNull: true, + }, + project_id: { + type: 'TEXT', + notNull: true + }, + title: { + type: 'TEXT', + notNull: true + }, + description: { + type: 'TEXT', + notNull: true + }, + status: { + type: STATUS_TYPE, + notNull: true, + }, + updated_by: { + type: 'TEXT', + }, + updated_at: { + type: 'TIMESTAMPTZ', + }, + created_by: { + type: 'TEXT', + notNull: true, + }, + created_at: { + type: 'TIMESTAMPTZ', + notNull: true, + default: pgm.func('CURRENT_TIMESTAMP') + }, + }) + + pgm.createIndex(Model.tableName, 'project_id') + pgm.addConstraint(Model.tableName, 'project_id_fk', `FOREIGN KEY(project_id) REFERENCES ${ProjectModel.tableName}(id)`) +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.dropTable(Model.tableName) + pgm.dropType(STATUS_TYPE, { cascade: true }) +} diff --git a/src/migrations/1715087554704_create-project-milestone-updates-table.ts b/src/migrations/1715087554704_create-project-milestone-updates-table.ts new file mode 100644 index 000000000..b069866c5 --- /dev/null +++ b/src/migrations/1715087554704_create-project-milestone-updates-table.ts @@ -0,0 +1,47 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { MigrationBuilder } from "node-pg-migrate" +import Model from "../back/models/ProjectMilestoneUpdate" +import ProjectMilestoneModel from "../back/models/ProjectMilestone" + +const LEGACY_TABLE_NAME = 'proposal_updates' + +export async function up(pgm: MigrationBuilder): Promise { + pgm.createTable(Model.tableName, { + id: { + type: 'TEXT', + primaryKey: true, + notNull: true, + }, + update_id: { + type: 'TEXT', + notNull: true + }, + milestone_id: { + type: 'TEXT', + notNull: true + }, + description: { + type: 'TEXT', + notNull: true + }, + tasks: { + type: 'TEXT[]', + notNull: true, + default: '{}' + }, + created_at: { + type: 'TIMESTAMPTZ', + notNull: true, + default: pgm.func('CURRENT_TIMESTAMP') + }, + }) + + pgm.createIndex(Model.tableName, 'update_id') + pgm.addConstraint(Model.tableName, 'update_id_fk', `FOREIGN KEY(update_id) REFERENCES ${LEGACY_TABLE_NAME}(id)`) + pgm.createIndex(Model.tableName, 'milestone_id') + pgm.addConstraint(Model.tableName, 'milestone_id_fk', `FOREIGN KEY(milestone_id) REFERENCES ${ProjectMilestoneModel.tableName}(id)`) +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.dropTable(Model.tableName) +} diff --git a/src/migrations/1716383233795_milestone-delivery-date.ts b/src/migrations/1716383233795_milestone-delivery-date.ts new file mode 100644 index 000000000..90901ec3b --- /dev/null +++ b/src/migrations/1716383233795_milestone-delivery-date.ts @@ -0,0 +1,18 @@ +import { ColumnDefinitions, MigrationBuilder } from 'node-pg-migrate' + +import Model from '../back/models/ProjectMilestone' + +export const shorthands: ColumnDefinitions | undefined = undefined + +export async function up(pgm: MigrationBuilder): Promise { + pgm.addColumns(Model.tableName, { + delivery_date: { + type: 'TIMESTAMPTZ', + notNull: true, + }, + }) +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.dropColumn(Model.tableName, 'delivery_date') +} diff --git a/src/migrations/1716924746895_create-project-links-table.ts b/src/migrations/1716924746895_create-project-links-table.ts new file mode 100644 index 000000000..b438f94f7 --- /dev/null +++ b/src/migrations/1716924746895_create-project-links-table.ts @@ -0,0 +1,47 @@ +import { MigrationBuilder } from "node-pg-migrate" +import ProjectModel from "../back/models/Project" +import Model from "../back/models/ProjectLink" + +export async function up(pgm: MigrationBuilder): Promise { + pgm.createTable(Model.tableName, { + id: { + type: 'TEXT', + primaryKey: true, + notNull: true, + }, + project_id: { + type: 'TEXT', + notNull: true + }, + label: { + type: 'TEXT', + notNull: true + }, + url: { + type: 'TEXT', + notNull: true + }, + updated_by: { + type: 'TEXT', + }, + updated_at: { + type: 'TIMESTAMPTZ', + }, + created_by: { + type: 'TEXT', + notNull: true, + }, + created_at: { + type: 'TIMESTAMPTZ', + notNull: true, + default: pgm.func('CURRENT_TIMESTAMP') + }, + }) + + pgm.createIndex(Model.tableName, 'project_id') + pgm.addConstraint(Model.tableName, 'project_id_fk', `FOREIGN KEY(project_id) REFERENCES ${ProjectModel.tableName}(id)`) +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.dropTable(Model.tableName) +} diff --git a/src/migrations/1716925443361_remove-project-link-column.ts b/src/migrations/1716925443361_remove-project-link-column.ts new file mode 100644 index 000000000..7591a3bfc --- /dev/null +++ b/src/migrations/1716925443361_remove-project-link-column.ts @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { ColumnDefinitions, MigrationBuilder } from "node-pg-migrate" +import Model from "../back/models/Project" + +export const shorthands: ColumnDefinitions | undefined = undefined + +export async function up(pgm: MigrationBuilder): Promise { + pgm.dropColumns(Model.tableName, [ 'links' ]) +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.addColumns(Model.tableName, { + links: { + type: 'TEXT[]', + notNull: true, + default: '{}' + }, + }) +} diff --git a/src/migrations/1717080580412_alter-updates-table-name.ts b/src/migrations/1717080580412_alter-updates-table-name.ts new file mode 100644 index 000000000..5095e903a --- /dev/null +++ b/src/migrations/1717080580412_alter-updates-table-name.ts @@ -0,0 +1,9 @@ +import type { MigrationBuilder } from "node-pg-migrate" + +export async function up(pgm: MigrationBuilder): Promise { + pgm.renameTable('proposal_updates', 'project_updates') +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.renameTable('project_updates', 'proposal_updates') +} \ No newline at end of file diff --git a/src/migrations/1717082952079_add-project-id-column-to-updates-table.ts b/src/migrations/1717082952079_add-project-id-column-to-updates-table.ts new file mode 100644 index 000000000..eb455c92a --- /dev/null +++ b/src/migrations/1717082952079_add-project-id-column-to-updates-table.ts @@ -0,0 +1,30 @@ +import type { MigrationBuilder } from "node-pg-migrate" +import UpdateModel from "../entities/Updates/model" +import ProjectModel from "../back/models/Project" + +export async function up(pgm: MigrationBuilder): Promise { + pgm.addColumn(UpdateModel.tableName, { + project_id: { + type: 'TEXT', + notNull: false, + }, + }) + pgm.addConstraint(UpdateModel.tableName, 'fk_project_id', { + foreignKeys: { + columns: ['project_id'], + references: `${ProjectModel.tableName}(id)`, + }, + }) + const query = ` + UPDATE ${UpdateModel.tableName} + SET project_id = p.id + FROM ${ProjectModel.tableName} p + WHERE ${UpdateModel.tableName}.proposal_id = p.proposal_id + ` + pgm.sql(query) +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.dropConstraint(UpdateModel.tableName, 'fk_project_id', {ifExists: true}) + pgm.dropColumn(UpdateModel.tableName, 'project_id') +} \ No newline at end of file diff --git a/src/migrations/1717171439267_add-unique-constraint-project-table.ts b/src/migrations/1717171439267_add-unique-constraint-project-table.ts new file mode 100644 index 000000000..f1d54aaa8 --- /dev/null +++ b/src/migrations/1717171439267_add-unique-constraint-project-table.ts @@ -0,0 +1,14 @@ +import type { MigrationBuilder } from "node-pg-migrate" +import ProjectModel from "../back/models/Project" + +const constraintName = 'unique_proposal_id' + +export async function up(pgm: MigrationBuilder): Promise { + pgm.addConstraint(ProjectModel.tableName, constraintName, { + unique: ['proposal_id'], + }) +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.dropConstraint(ProjectModel.tableName, constraintName) +} \ No newline at end of file diff --git a/src/services/BidService.ts b/src/services/BidService.ts index 6efa02616..fb4823bc9 100644 --- a/src/services/BidService.ts +++ b/src/services/BidService.ts @@ -1,7 +1,7 @@ import JobContext from 'decentraland-gatsby/dist/entities/Job/context' import UnpublishedBidModel from '../entities/Bid/model' -import { UnpublishedBidAttributes, UnpublishedBidStatus } from '../entities/Bid/types' +import { BidProposalConfiguration, UnpublishedBidAttributes, UnpublishedBidStatus } from '../entities/Bid/types' import { GATSBY_GRANT_VP_THRESHOLD } from '../entities/Grant/constants' import ProposalModel from '../entities/Proposal/model' import { ProposalType } from '../entities/Proposal/types' @@ -15,6 +15,7 @@ import { ErrorService } from './ErrorService' import { ProposalService } from './ProposalService' const MINIMUM_BIDS_TO_PUBLISH = Number(process.env.MINIMUM_BIDS_TO_PUBLISH || 0) + export default class BidService { static async createBid( linked_proposal_id: string, @@ -102,16 +103,18 @@ export default class BidService { ? Number(GATSBY_GRANT_VP_THRESHOLD) : getHighBudgetVpThreshold(Number(bid_proposal_data.funding)) + const bidProposalConfiguration: BidProposalConfiguration = { + bid_number, + linked_proposal_id, + created_at, + ...bid_proposal_data, + choices: DEFAULT_CHOICES, + } + await ProposalService.createProposal({ type: ProposalType.Bid, user: author_address, - configuration: { - bid_number, - linked_proposal_id, - created_at, - ...bid_proposal_data, - choices: DEFAULT_CHOICES, - }, + configuration: bidProposalConfiguration, required_to_pass, finish_at, }) diff --git a/src/services/ProjectService.ts b/src/services/ProjectService.ts index 7977c0925..5ab0671c3 100644 --- a/src/services/ProjectService.ts +++ b/src/services/ProjectService.ts @@ -1,27 +1,40 @@ +import crypto from 'crypto' + +import PersonnelModel, { PersonnelAttributes } from '../back/models/Personnel' +import ProjectModel, { Project, ProjectAttributes } from '../back/models/Project' +import ProjectLinkModel, { ProjectLink } from '../back/models/ProjectLink' +import ProjectMilestoneModel, { ProjectMilestone, ProjectMilestoneStatus } from '../back/models/ProjectMilestone' import { TransparencyVesting } from '../clients/Transparency' +import { getVestingWithLogs } from '../clients/VestingData' import UnpublishedBidModel from '../entities/Bid/model' +import { BidProposalConfiguration } from '../entities/Bid/types' import { GrantTier } from '../entities/Grant/GrantTier' import { GRANT_PROPOSAL_DURATION_IN_SECONDS } from '../entities/Grant/constants' -import { GrantRequest, ProjectStatus } from '../entities/Grant/types' +import { GrantRequest, ProjectStatus, VestingStatus } from '../entities/Grant/types' +import { PersonnelInCreation, ProjectLinkInCreation, ProjectMilestoneInCreation } from '../entities/Project/types' import ProposalModel from '../entities/Proposal/model' +import { ProposalWithOutcome } from '../entities/Proposal/outcome' import { GrantProposalConfiguration, - ProjectWithUpdate, ProposalAttributes, + ProposalProjectWithUpdate, + ProposalStatus, ProposalType, } from '../entities/Proposal/types' -import { DEFAULT_CHOICES, asNumber, getProposalEndDate } from '../entities/Proposal/utils' +import { DEFAULT_CHOICES, asNumber, getProposalEndDate, isProjectProposal } from '../entities/Proposal/utils' import UpdateModel from '../entities/Updates/model' import { IndexedUpdate, UpdateAttributes } from '../entities/Updates/types' import { getPublicUpdates } from '../entities/Updates/utils' -import { formatError } from '../helpers' +import { formatError, inBackground } from '../helpers' import Time from '../utils/date/Time' +import { ErrorCategory } from '../utils/errorCategories' import { isProdEnv } from '../utils/governanceEnvs' import logger from '../utils/logger' -import { createProject } from '../utils/projects' +import { createProposalProject, toGovernanceProjectStatus } from '../utils/projects' import { BudgetService } from './BudgetService' -import { ProposalInCreation } from './ProposalService' +import { ErrorService } from './ErrorService' +import { ProposalInCreation, ProposalService } from './ProposalService' import { VestingService } from './VestingService' function newestVestingFirst(a: TransparencyVesting, b: TransparencyVesting): number { @@ -32,25 +45,25 @@ function newestVestingFirst(a: TransparencyVesting, b: TransparencyVesting): num } export class ProjectService { - public static async getProjects() { + public static async getProposalProjects() { const data = await ProposalModel.getProjectList() const vestings = await VestingService.getAllVestings() - const projects: ProjectWithUpdate[] = [] + const projects: ProposalProjectWithUpdate[] = [] await Promise.all( - data.map(async (proposal: ProposalAttributes) => { + data.map(async (proposal) => { try { const proposalVestings = vestings.filter((item) => item.proposal_id === proposal.id).sort(newestVestingFirst) const prioritizedVesting: TransparencyVesting | undefined = proposalVestings.find( (vesting) => - vesting.vesting_status === ProjectStatus.InProgress || vesting.vesting_status === ProjectStatus.Finished + vesting.vesting_status === VestingStatus.InProgress || vesting.vesting_status === VestingStatus.Finished ) || proposalVestings[0] - const project = createProject(proposal, prioritizedVesting) + const project = createProposalProject(proposal, prioritizedVesting) try { const update = await this.getProjectLatestUpdate(project.id) - const projectWithUpdate: ProjectWithUpdate = { + const projectWithUpdate: ProposalProjectWithUpdate = { ...project, ...this.getUpdateData(update), } @@ -147,4 +160,188 @@ export class ProjectService { total: Number(data.total), } } + + static async createProjects(proposalsWithOutcome: ProposalWithOutcome[]) { + inBackground(async () => { + const proposalsToCreateProjectFor = proposalsWithOutcome.filter( + (proposal) => isProjectProposal(proposal.type) && proposal.newStatus === ProposalStatus.Passed + ) + proposalsToCreateProjectFor.forEach((proposal) => ProjectService.createProject(proposal)) + }) + } + + static async migrateProjects() { + const migrationResult = { migratedProjects: 0, migrationsFinished: 0, migrationErrors: [] as string[], error: '' } + try { + const { data: projects } = await this.getProposalProjects() + const migratedProjects = await ProjectModel.migrate(projects) + migrationResult.migratedProjects = migratedProjects.length + + for (const project of migratedProjects) { + try { + const proposal = await ProposalService.getProposal(project.proposal_id) + await ProjectService.createPersonnel(proposal, project, new Date(project.created_at)) + await ProjectService.createMilestones(proposal, project, new Date(project.created_at)) + migrationResult.migrationsFinished++ + } catch (e) { + migrationResult.migrationErrors.push(`Project ${project.id} failed with: ${e}`) + } + } + + await UpdateModel.setProjectIds() + return migrationResult + } catch (e) { + const message = `Migration failed: ${e}` + console.error(message) + migrationResult.error = message + return migrationResult + } + } + + private static async createProject(proposal: ProposalWithOutcome): Promise { + try { + const creationDate = new Date() + const newProject = await ProjectModel.create({ + id: crypto.randomUUID(), + proposal_id: proposal.id, + title: proposal.title, + about: proposal.configuration.abstract, + status: ProjectStatus.Pending, + created_at: creationDate, + }) + + await ProjectService.createPersonnel(proposal, newProject, creationDate) + await ProjectService.createMilestones(proposal, newProject, creationDate) + + return newProject + } catch (error) { + ErrorService.report('Error creating project', { + error: formatError(error as Error), + category: ErrorCategory.Project, + }) + return null + } + } + + private static async createPersonnel(proposal: ProposalAttributes, project: ProjectAttributes, creationDate: Date) { + const newPersonnel: PersonnelAttributes[] = [] + const config = + proposal.type === ProposalType.Grant + ? (proposal.configuration as GrantProposalConfiguration) + : (proposal.configuration as BidProposalConfiguration) + config.members?.forEach((member) => { + if (member) { + newPersonnel.push({ + ...member, + id: crypto.randomUUID(), + project_id: project.id, + created_at: creationDate, + deleted: false, + }) + } + }) + await PersonnelModel.createMany(newPersonnel) + } + + private static async createMilestones(proposal: ProposalAttributes, project: ProjectAttributes, creationDate: Date) { + const newMilestones: ProjectMilestone[] = [] + const config = + proposal.type === ProposalType.Grant + ? (proposal.configuration as GrantProposalConfiguration) + : (proposal.configuration as BidProposalConfiguration) + + config.milestones?.forEach((milestone) => { + newMilestones.push({ + id: crypto.randomUUID(), + project_id: project.id, + created_at: creationDate, + title: milestone.title, + description: milestone.tasks, + delivery_date: new Date(milestone.delivery_date), + status: ProjectMilestoneStatus.Pending, + created_by: proposal.user, + }) + }) + await ProjectMilestoneModel.createMany(newMilestones) + } + + static async getUpdatedProject(id: string) { + const project = await ProjectModel.getProject(id) + if (!project) { + throw new Error(`Project not found: "${id}"`) + } + return await ProjectService.updateStatusFromVesting(project) + } + + private static async updateStatusFromVesting(project: Project) { + try { + const latestVesting = project.vesting_addresses[project.vesting_addresses.length - 1] + const vestingWithLogs = await getVestingWithLogs(latestVesting) + const updatedProjectStatus = toGovernanceProjectStatus(vestingWithLogs.status) + await ProjectModel.update({ status: updatedProjectStatus, updated_at: new Date() }, { id: project.id }) + + return { + ...project, + status: updatedProjectStatus, + funding: { vesting: vestingWithLogs, enacted_at: vestingWithLogs.start_at }, + } + } catch (error) { + ErrorService.report('Unable to update project status', { error, id: project.id, category: ErrorCategory.Project }) + return project + } + } + + static async addPersonnel(newPersonnel: PersonnelInCreation, user?: string) { + const { address } = newPersonnel + return await PersonnelModel.create({ + ...newPersonnel, + address: address && address?.length > 0 ? address : null, + id: crypto.randomUUID(), + updated_by: user, + created_at: new Date(), + deleted: false, + }) + } + + static async deletePersonnel(personnel_id: PersonnelAttributes['id'], user: string) { + const result = await PersonnelModel.update( + { deleted: true, updated_by: user, updated_at: new Date() }, + { id: personnel_id } + ) + return !!result && result.rowCount === 1 ? personnel_id : null + } + + static async addLink(newLink: ProjectLinkInCreation, user: string) { + return await ProjectLinkModel.create({ + ...newLink, + id: crypto.randomUUID(), + created_by: user, + created_at: new Date(), + }) + } + + static async deleteLink(linkId: ProjectLink['id']) { + const result = await ProjectLinkModel.delete({ id: linkId }) + return !!result && result.rowCount === 1 ? linkId : null + } + + static async addMilestone(newMilestone: ProjectMilestoneInCreation, user: string) { + return await ProjectMilestoneModel.create({ + ...newMilestone, + status: ProjectMilestoneStatus.Pending, + id: crypto.randomUUID(), + created_by: user, + created_at: new Date(), + }) + } + + static async deleteMilestone(milestone_id: ProjectMilestone['id']) { + const result = await ProjectMilestoneModel.delete({ id: milestone_id }) + + return !!result && result.rowCount === 1 ? milestone_id : null + } + + static async isAuthorOrCoauthor(user: string, projectId: string) { + return await ProjectModel.isAuthorOrCoauthor(user, projectId) + } } diff --git a/src/services/ProposalService.ts b/src/services/ProposalService.ts index e5ccb120c..263569ab0 100644 --- a/src/services/ProposalService.ts +++ b/src/services/ProposalService.ts @@ -5,6 +5,8 @@ import RequestError from 'decentraland-gatsby/dist/entities/Route/error' import { DiscordService } from '../back/services/discord' import { EventsService } from '../back/services/events' import { NotificationService } from '../back/services/notification' +import { UpdateService } from '../back/services/update' +import { validateId } from '../back/utils/validations' import { SnapshotProposalContent } from '../clients/SnapshotTypes' import UnpublishedBidModel from '../entities/Bid/model' import CoauthorModel from '../entities/Coauthor/model' @@ -12,8 +14,15 @@ import isDAOCommittee from '../entities/Committee/isDAOCommittee' import ProposalModel from '../entities/Proposal/model' import { ProposalWithOutcome } from '../entities/Proposal/outcome' import * as templates from '../entities/Proposal/templates' -import { PriorityProposalType, ProposalAttributes, ProposalStatus, ProposalType } from '../entities/Proposal/types' -import { isGrantProposalSubmitEnabled } from '../entities/Proposal/utils' +import { + PriorityProposalType, + ProposalAttributes, + ProposalStatus, + ProposalStatusUpdate, + ProposalType, + ProposalWithProject, +} from '../entities/Proposal/types' +import { isGrantProposalSubmitEnabled, isProjectProposal } from '../entities/Proposal/utils' import { SNAPSHOT_SPACE } from '../entities/Snapshot/constants' import VotesModel from '../entities/Votes/model' import { getEnvironmentChainId } from '../helpers' @@ -22,6 +31,7 @@ import { getProfile } from '../utils/Catalyst' import Time from '../utils/date/Time' import { DiscourseService } from './DiscourseService' +import { ProjectService } from './ProjectService' import { SnapshotService } from './SnapshotService' export type ProposalInCreation = { @@ -190,7 +200,7 @@ export class ProposalService { rejected_description: null, created_at: proposalLifespan.created.toJSON() as any, updated_at: proposalLifespan.created.toJSON() as any, - textsearch: ProposalModel.textsearch(title, description, data.user, []), + textsearch: ProposalModel.generateTextSearchVector(title, description, data.user, []), } try { @@ -217,6 +227,14 @@ export class ProposalService { return ProposalModel.parse(proposal) } + static async getProposalWithProject(id: string) { + const proposal = await ProposalModel.getProposalWithProject(id) + if (!proposal) { + throw new Error(`Proposal not found: "${id}"`) + } + return proposal + } + static getFinishProposalQueries(proposalsWithOutcome: ProposalWithOutcome[]) { const proposalUpdateQueriesByStatus: SQLStatement[] = [] Object.values(ProposalStatus).forEach((proposalStatus) => { @@ -255,4 +273,65 @@ export class ProposalService { return priorityProposalsWithBidsInfo } + + static async updateProposalStatus( + proposal: ProposalWithProject, + statusUpdate: ProposalStatusUpdate, + user: string + ): Promise { + const { status: newStatus, vesting_addresses } = statusUpdate + const { id } = proposal + const isProject = isProjectProposal(proposal.type) + const isEnactedStatus = newStatus === ProposalStatus.Enacted + const updated_at = new Date() + let update: Partial = { + status: newStatus, + updated_at, + } + + if (isEnactedStatus) { + update = { ...update, ...this.getEnactedStatusData(proposal, vesting_addresses, user) } + } else if (newStatus === ProposalStatus.Passed) { + update.passed_by = user + } else if (newStatus === ProposalStatus.Rejected) { + update.rejected_by = user + } + await ProposalModel.update(update, { id }) + + const updatedProposal = { ...proposal, ...update } + + if (isEnactedStatus && isProject) { + const validatedProjectId = validateId(proposal.project_id) + await UpdateService.createPendingUpdatesForVesting(validatedProjectId, vesting_addresses) + const project = await ProjectService.getUpdatedProject(proposal.project_id!) + updatedProposal.project_status = project.status + NotificationService.projectProposalEnacted(proposal) + } + + DiscourseService.commentUpdatedProposal(updatedProposal) + + return updatedProposal + } + + private static getEnactedStatusData( + proposal: ProposalAttributes, + vesting_addresses: string[] | undefined, + user: string + ) { + const update: Partial = { + enacted: true, + enacted_by: user, + } + + if (isProjectProposal(proposal.type)) { + update.vesting_addresses = vesting_addresses || [] + update.textsearch = ProposalModel.generateTextSearchVector( + proposal.title, + proposal.description, + proposal.user, + update.vesting_addresses + ) + } + return update + } } diff --git a/src/services/VestingService.ts b/src/services/VestingService.ts index 98ad1edbd..6bc77667a 100644 --- a/src/services/VestingService.ts +++ b/src/services/VestingService.ts @@ -1,5 +1,5 @@ import { Transparency, TransparencyVesting } from '../clients/Transparency' -import { VestingInfo, getVestingContractData } from '../clients/VestingData' +import { VestingWithLogs, getVestingWithLogs } from '../clients/VestingData' import CacheService, { TTL_24_HS } from './CacheService' @@ -17,16 +17,16 @@ export class VestingService { return transparencyVestings } - static async getVestingInfo(addresses: string[]): Promise { - const vestings = await Promise.all(addresses.map((address) => getVestingContractData(address))) + static async getVestings(addresses: string[]): Promise { + const vestings = await Promise.all(addresses.map((address) => getVestingWithLogs(address))) return vestings.sort(compareVestingInfo) } } -function compareVestingInfo(a: VestingInfo, b: VestingInfo): number { +function compareVestingInfo(a: VestingWithLogs, b: VestingWithLogs): number { if (a.logs.length === 0 && b.logs.length === 0) { - return new Date(b.vestingStartAt).getTime() - new Date(a.vestingStartAt).getTime() + return new Date(b.start_at).getTime() - new Date(a.start_at).getTime() } if (a.logs.length === 0) { diff --git a/src/utils/errorCategories.ts b/src/utils/errorCategories.ts index 1265c81ff..ca6a1d344 100644 --- a/src/utils/errorCategories.ts +++ b/src/utils/errorCategories.ts @@ -7,6 +7,7 @@ export enum ErrorCategory { Events = 'Events', Job = 'Job', Profile = 'Profile', + Project = 'Project', Proposal = 'Proposal', Snapshot = 'Snapshot', Transparency = 'Transparency', diff --git a/src/utils/projects.ts b/src/utils/projects.ts index 0d2d9684d..53cabe7d4 100644 --- a/src/utils/projects.ts +++ b/src/utils/projects.ts @@ -1,138 +1,109 @@ import { TransparencyVesting } from '../clients/Transparency' -import { ProjectStatus } from '../entities/Grant/types' -import { Project, ProposalAttributes } from '../entities/Proposal/types' - -import Time from './date/Time' - -function getQuarterDates(quarter?: number, year?: number) { - if (!quarter && !year) { - return { startDate: undefined, endDate: undefined } - } - - if (!year) { - console.error('Year is required') - return { startDate: undefined, endDate: undefined } - } - - if (!quarter) { - const startDate = Time(`${year}-01-01`).format('YYYY-MM-DD') - const endDate = Time(`${year}-12-31`).format('YYYY-MM-DD') - return { startDate, endDate } - } - - if (quarter < 1 || quarter > 4) { - console.error('Quarter should be between 1 and 4') - return { startDate: undefined, endDate: undefined } - } - - const startMonth = (quarter - 1) * 3 + 1 - - const endMonth = startMonth + 2 - - const startDate = Time(`${year}-${startMonth}-01`).startOf('month').format('YYYY-MM-DD') - const endDate = Time(`${year}-${endMonth}-01`).endOf('month').add(1, 'day').format('YYYY-MM-DD') - - return { startDate, endDate } -} +import { Vesting } from '../clients/VestingData' +import { ProjectStatus, VestingStatus } from '../entities/Grant/types' +import { ProjectFunding, ProposalAttributes, ProposalProject, ProposalWithProject } from '../entities/Proposal/types' export function getHighBudgetVpThreshold(budget: number) { return 1200000 + budget * 40 } -export function getGoogleCalendarUrl({ - title, - details, - startAt, -}: { - title: string - details: string - startAt: string | Date -}) { - const params = new URLSearchParams() - params.set('text', title) - params.set('details', details) - const startAtDate = Time.from(startAt, { utc: true }) - const dates = [ - startAtDate.format(Time.Formats.GoogleCalendar), - Time.from(startAt, { utc: true }).add(15, 'minutes').format(Time.Formats.GoogleCalendar), - ] - params.set('dates', dates.join('/')) - - return `https://calendar.google.com/calendar/r/eventedit?${params.toString()}` -} - -export function isCurrentProject(status?: ProjectStatus) { - return status === ProjectStatus.InProgress || status === ProjectStatus.Paused || status === ProjectStatus.Pending +export function toGovernanceProjectStatus(status: VestingStatus) { + switch (status) { + case VestingStatus.Pending: + return ProjectStatus.Pending + case VestingStatus.InProgress: + return ProjectStatus.InProgress + case VestingStatus.Finished: + return ProjectStatus.Finished + case VestingStatus.Paused: + return ProjectStatus.Paused + case VestingStatus.Revoked: + return ProjectStatus.Revoked + } } -export function isCurrentQuarterProject(year: number, quarter: number, startAt?: number) { - if (!startAt) { - return false +function getFunding(proposal: ProposalAttributes, transparencyVesting?: TransparencyVesting): ProjectFunding { + if (proposal.enacting_tx) { + // one time payment + return { + enacted_at: proposal.updated_at.toISOString(), + one_time_payment: { + enacting_tx: proposal.enacting_tx, + }, + } } - const { startDate, endDate } = getQuarterDates(quarter, year) - - if (!startDate || !endDate) { - return false + if (!transparencyVesting) { + return {} } - return Time.unix(startAt || 0).isAfter(startDate) && Time.unix(startAt || 0).isBefore(endDate) + return { + enacted_at: transparencyVesting.vesting_start_at, + vesting: toVesting(transparencyVesting), + } } -function getProjectVestingData(proposal: ProposalAttributes, vesting: TransparencyVesting) { - if (proposal.enacting_tx) { - return { - status: ProjectStatus.Finished, - enacting_tx: proposal.enacting_tx, - enacted_at: Time(proposal.updated_at).unix(), - } +function getProjectStatus(proposal: ProposalAttributes, vesting?: TransparencyVesting) { + const legacyCondition = !vesting && proposal.enacted_description + if (proposal.enacting_tx || legacyCondition) { + return ProjectStatus.Finished } if (!vesting) { - return { - status: ProjectStatus.Pending, - } + return ProjectStatus.Pending } - const { - vesting_status: status, - token, - vesting_start_at, - vesting_finish_at, - vesting_total_amount, - vesting_released, - vesting_releasable, - } = vesting + const { vesting_status } = vesting - return { - status, - token, - enacted_at: Time(vesting_start_at).unix(), - contract: { - vesting_total_amount: Math.round(vesting_total_amount), - vestedAmount: Math.round(vesting_released + vesting_releasable), - releasable: Math.round(vesting_releasable), - released: Math.round(vesting_released), - start_at: Time(vesting_start_at).unix(), - finish_at: Time(vesting_finish_at).unix(), - }, - } + return toGovernanceProjectStatus(vesting_status) } -export function createProject(proposal: ProposalAttributes, vesting?: TransparencyVesting): Project { - const vestingData = vesting ? getProjectVestingData(proposal, vesting) : {} +export function createProposalProject(proposal: ProposalWithProject, vesting?: TransparencyVesting): ProposalProject { + const funding = getFunding(proposal, vesting) + const status = getProjectStatus(proposal, vesting) return { id: proposal.id, + project_id: proposal.project_id, + status, title: proposal.title, user: proposal.user, + about: proposal.configuration.abstract, type: proposal.type, size: proposal.configuration.size || proposal.configuration.funding, created_at: proposal.created_at.getTime(), + updated_at: proposal.updated_at.getTime(), configuration: { category: proposal.configuration.category || proposal.type, tier: proposal.configuration.tier, }, - ...vestingData, + funding, } } + +export function toVesting(transparencyVesting: TransparencyVesting): Vesting { + const { + token, + vesting_start_at, + vesting_finish_at, + vesting_total_amount, + vesting_released, + vesting_releasable, + vesting_status, + vesting_address, + } = transparencyVesting + + const vesting: Vesting = { + token, + address: vesting_address, + start_at: vesting_start_at, + finish_at: vesting_finish_at, + releasable: Math.round(vesting_releasable), + released: Math.round(vesting_released), + total: Math.round(vesting_total_amount), + vested: Math.round(vesting_released + vesting_releasable), + status: vesting_status, + } + + return vesting +}