From c930b31759bc88041aa18ddcb3bf0b08efd96a82 Mon Sep 17 00:00:00 2001 From: 1emu Date: Thu, 8 Aug 2024 15:48:57 -0300 Subject: [PATCH] chore: updated projects endpoint --- src/models/Project.ts | 43 ++++++++++++++++++++++++++++++- src/routes/project.ts | 13 ++++++++++ src/services/ProjectService.ts | 46 +++++++++++++++++++++++++++++----- src/utils/projects.ts | 43 ++++++++++++++++++++++++++++--- 4 files changed, 135 insertions(+), 10 deletions(-) diff --git a/src/models/Project.ts b/src/models/Project.ts index 3d5bfdfcb..843f1cdf6 100644 --- a/src/models/Project.ts +++ b/src/models/Project.ts @@ -7,7 +7,9 @@ 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 } from '../entities/Proposal/types' +import { ProjectFunding, ProposalAttributes } from '../entities/Proposal/types' +import UpdateModel from '../entities/Updates/model' +import { UpdateAttributes } from '../entities/Updates/types' import PersonnelModel, { PersonnelAttributes } from './Personnel' import ProjectLinkModel, { ProjectLink } from './ProjectLink' @@ -33,8 +35,14 @@ export type Project = ProjectAttributes & { coauthors: string[] | null vesting_addresses: string[] funding?: ProjectFunding + updates?: UpdateAttributes } +export type ProjectQueryResult = Project & + Pick & { + proposal_updated_at: string + } + export default class ProjectModel extends Model { static tableName = 'projects' static withTimestamps = false @@ -96,4 +104,37 @@ export default class ProjectModel extends Model { const result = await this.namedQuery<{ exists: boolean }>(`is_author_or_coauthor`, query) return result[0]?.exists || false } + + static async getProjectsWithUpdates(): Promise { + 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(DISTINCT co.address) FILTER (WHERE co.address IS NOT NULL), '{}') AS coauthors, + COALESCE(json_agg(DISTINCT to_jsonb(ordered_updates.*)) FILTER + (WHERE ordered_updates.id IS NOT NULL), '[]') as updates, + p.enacting_tx, + p.enacted_description, + p.updated_at as proposal_updated_at + 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} + LEFT JOIN (SELECT * FROM ${table( + UpdateModel + )} up ORDER BY up.created_at DESC) ordered_updates ON pr.id = ordered_updates.project_id + GROUP BY pr.id, p.user, p.vesting_addresses, p.enacting_tx, p.enacted_description, proposal_updated_at; + + ` + + const result = await this.namedQuery(`get_projects`, query) + return result || [] + } } diff --git a/src/routes/project.ts b/src/routes/project.ts index a7cb44201..7cd08b6b6 100644 --- a/src/routes/project.ts +++ b/src/routes/project.ts @@ -11,15 +11,19 @@ import { } from '../entities/Project/types' import { ProposalProjectWithUpdate } from '../entities/Proposal/types' import PersonnelModel, { PersonnelAttributes } from '../models/Personnel' +import { Project } from '../models/Project' import ProjectLinkModel, { ProjectLink } from '../models/ProjectLink' import ProjectMilestoneModel, { ProjectMilestone } from '../models/ProjectMilestone' import CacheService, { TTL_1_HS } from '../services/CacheService' +import { ErrorService } from '../services/ErrorService' import { ProjectService } from '../services/ProjectService' +import { ErrorCategory } from '../utils/errorCategories' import { isValidDate, validateCanEditProject, validateId } from '../utils/validations' export default routes((route) => { const withAuth = auth() route.get('/projects', handleJSON(getProjects)) + route.get('/projects/updated/', handleJSON(getUpdatedProjects)) route.post('/projects/personnel/', withAuth, handleAPI(addPersonnel)) route.delete('/projects/personnel/:personnel_id', withAuth, handleAPI(deletePersonnel)) route.post('/projects/links/', withAuth, handleAPI(addLink)) @@ -60,6 +64,15 @@ async function getProjects(req: Request) { return { data: filterProjectsByDate(projects, from, to) } } +async function getUpdatedProjects(): Promise { + try { + return await ProjectService.getUpdatedProjects() + } catch (error) { + ErrorService.report('Error fetching projets', { error, category: ErrorCategory.Project }) + throw new RequestError(`Unable to load projects`, RequestError.InternalServerError) + } +} + async function getProject(req: Request<{ project: string }>) { const id = validateId(req.params.project) try { diff --git a/src/services/ProjectService.ts b/src/services/ProjectService.ts index b7e825d77..ccc1f0d66 100644 --- a/src/services/ProjectService.ts +++ b/src/services/ProjectService.ts @@ -1,6 +1,7 @@ import crypto from 'crypto' import { TransparencyVesting } from '../clients/Transparency' +import { VestingWithLogs } from '../clients/VestingData' import UnpublishedBidModel from '../entities/Bid/model' import { BidProposalConfiguration } from '../entities/Bid/types' import { GrantTier } from '../entities/Grant/GrantTier' @@ -22,14 +23,19 @@ import { IndexedUpdate, UpdateAttributes } from '../entities/Updates/types' import { getPublicUpdates } from '../entities/Updates/utils' import { formatError, inBackground } from '../helpers' import PersonnelModel, { PersonnelAttributes } from '../models/Personnel' -import ProjectModel, { Project, ProjectAttributes } from '../models/Project' +import ProjectModel, { Project, ProjectAttributes, ProjectQueryResult } from '../models/Project' import ProjectLinkModel, { ProjectLink } from '../models/ProjectLink' import ProjectMilestoneModel, { ProjectMilestone, ProjectMilestoneStatus } from '../models/ProjectMilestone' import Time from '../utils/date/Time' import { ErrorCategory } from '../utils/errorCategories' import { isProdEnv } from '../utils/governanceEnvs' import logger from '../utils/logger' -import { createProposalProject, toGovernanceProjectStatus } from '../utils/projects' +import { + createProposalProject, + getProjectFunding, + getProjectStatus, + toGovernanceProjectStatus, +} from '../utils/projects' import { BudgetService } from './BudgetService' import { ErrorService } from './ErrorService' @@ -47,7 +53,7 @@ export class ProjectService { public static async getProposalProjects() { const proposalWithProjects = await ProposalModel.getProjectList() const vestings = await VestingService.getAllVestings() - const projects: ProposalProjectWithUpdate[] = [] + const proposalProjects: ProposalProjectWithUpdate[] = [] await Promise.all( proposalWithProjects.map(async (proposal) => { @@ -57,7 +63,7 @@ export class ProjectService { proposalVestings.find( (vesting) => vesting.vesting_status === VestingStatus.InProgress || vesting.vesting_status === VestingStatus.Finished - ) || proposalVestings[0] //TODO: replace transparency vestings for vestings subgraph + ) || proposalVestings[0] const project = createProposalProject(proposal, prioritizedVesting) try { @@ -67,7 +73,7 @@ export class ProjectService { ...this.getUpdateData(update), } - return projects.push(projectWithUpdate) + return proposalProjects.push(projectWithUpdate) } catch (error) { logger.error(`Failed to fetch grant update data from proposal ${project.id}`, formatError(error as Error)) } @@ -78,8 +84,36 @@ export class ProjectService { } }) ) + return proposalProjects + } + + public static async getUpdatedProjects() { + const projectsQueryResults = await ProjectModel.getProjectsWithUpdates() + const latestVestingAddresses = projectsQueryResults.map( + (project) => project.vesting_addresses[project.vesting_addresses.length - 1] + ) + const latestVestings = await VestingService.getVestings(latestVestingAddresses) + const updatedProjects = this.mergeProjectsWithVestings(projectsQueryResults, latestVestings) + return updatedProjects + } - return projects + private static mergeProjectsWithVestings( + projects: ProjectQueryResult[], + latestVestings: VestingWithLogs[] + ): Project[] { + return ( + projects.map((project) => { + const latestVestingAddress = project.vesting_addresses[project.vesting_addresses.length - 1] + const vestingWithLogs = latestVestings.find((vesting) => vesting.address === latestVestingAddress) + const funding = getProjectFunding(project, vestingWithLogs) + const status = getProjectStatus(project, vestingWithLogs) + return { + ...project, + status, + funding, + } + }) || [] + ) } private static getUpdateData(update: (UpdateAttributes & { index: number }) | null) { diff --git a/src/utils/projects.ts b/src/utils/projects.ts index c8a18e6bd..a33566eaf 100644 --- a/src/utils/projects.ts +++ b/src/utils/projects.ts @@ -1,8 +1,9 @@ import { TransparencyVesting } from '../clients/Transparency' -import { Vesting } from '../clients/VestingData' +import { Vesting, VestingWithLogs } from '../clients/VestingData' import { ProjectStatus, VestingStatus } from '../entities/Grant/types' import { ProjectFunding, ProposalAttributes, ProposalProject, ProposalWithProject } from '../entities/Proposal/types' import { CLIFF_PERIOD_IN_DAYS } from '../entities/Proposal/utils' +import { ProjectQueryResult } from '../models/Project' import Time from './date/Time' @@ -46,7 +47,43 @@ function getFunding(proposal: ProposalAttributes, transparencyVesting?: Transpar } } -function getProjectStatus(proposal: ProposalAttributes, vesting?: TransparencyVesting) { +export function getProjectFunding(project: ProjectQueryResult, vesting: VestingWithLogs | undefined): ProjectFunding { + if (project.enacting_tx) { + // one time payment + return { + enacted_at: project.proposal_updated_at, + one_time_payment: { + enacting_tx: project.enacting_tx, + }, + } + } + + if (!vesting) { + return {} + } + + return { + enacted_at: vesting.start_at, + vesting, + } +} + +export function getProjectStatus(project: ProjectQueryResult, vesting: VestingWithLogs | undefined) { + const legacyCondition = !vesting && project.enacted_description + if (project.enacting_tx || legacyCondition) { + return ProjectStatus.Finished + } + + if (!vesting) { + return ProjectStatus.Pending + } + + const { status } = vesting + + return toGovernanceProjectStatus(status) +} + +function getProposalProjectStatus(proposal: ProposalAttributes, vesting?: TransparencyVesting) { const legacyCondition = !vesting && proposal.enacted_description if (proposal.enacting_tx || legacyCondition) { return ProjectStatus.Finished @@ -63,7 +100,7 @@ function getProjectStatus(proposal: ProposalAttributes, vesting?: TransparencyVe export function createProposalProject(proposal: ProposalWithProject, vesting?: TransparencyVesting): ProposalProject { const funding = getFunding(proposal, vesting) - const status = getProjectStatus(proposal, vesting) + const status = getProposalProjectStatus(proposal, vesting) return { id: proposal.id,