diff --git a/src/clients/Transparency.ts b/src/clients/Transparency.ts index 92981fda6..fb82a1a96 100644 --- a/src/clients/Transparency.ts +++ b/src/clients/Transparency.ts @@ -1,4 +1,3 @@ -import { VestingStatus } from '../entities/Grant/types' import { TokenInWallet } from '../entities/Transparency/types' import { ErrorCategory } from '../utils/errorCategories' @@ -53,20 +52,6 @@ export type TransparencyBudget = { category_percentages: Record } -export type TransparencyVesting = { - proposal_id: string - token: string - vesting_address: string - vesting_released: number - vesting_releasable: number - vesting_start_at: string - vesting_finish_at: string - vesting_contract_token_balance: number - vesting_total_amount: number - vesting_status: VestingStatus - duration_in_months: number -} - const EMPTY_API: TransparencyData = { balances: [], income: { @@ -108,14 +93,4 @@ export class Transparency { return [] } } - - static async getVestings() { - try { - const response = (await (await fetch(`${API_URL}/vestings.json`)).json()) as TransparencyVesting[] - return response - } catch (error) { - ErrorClient.report('Failed to fetch transparency vestings data', { error, category: ErrorCategory.Transparency }) - return [] - } - } } diff --git a/src/clients/VestingData.ts b/src/clients/VestingData.ts index d68a2b06c..e6f4a86c3 100644 --- a/src/clients/VestingData.ts +++ b/src/clients/VestingData.ts @@ -246,6 +246,8 @@ export function getTokenSymbolFromAddress(tokenAddress: string) { return 'USDC' case '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2': return 'WETH' + default: + console.log(`Unable to parse token contract address: ${tokenAddress}`) + return 'ETH' } - throw new Error(`Unable to parse token contract address: ${tokenAddress}`) } diff --git a/src/clients/VestingsSubgraph.ts b/src/clients/VestingsSubgraph.ts index 7f8fa6d32..1bdcd6d2f 100644 --- a/src/clients/VestingsSubgraph.ts +++ b/src/clients/VestingsSubgraph.ts @@ -5,6 +5,8 @@ import { VESTINGS_QUERY_ENDPOINT } from '../entities/Snapshot/constants' import { SubgraphVesting } from './VestingSubgraphTypes' import { trimLastForwardSlash } from './utils' +const OLDEST_INDEXED_BLOCK = 20463272 + export class VestingsSubgraph { static Cache = new Map() private readonly queryEndpoint: string @@ -72,7 +74,7 @@ export class VestingsSubgraph { } ` - const variables = { address } + const variables = { address: address.toLowerCase() } const response = await fetch(this.queryEndpoint, { method: 'post', headers: { 'Content-Type': 'application/json' }, @@ -86,10 +88,15 @@ export class VestingsSubgraph { return body?.data?.vestings[0] || {} } - async getVestings(addresses: string[]): Promise { + async getVestings(addresses?: string[]): Promise { + const queryAddresses = addresses && addresses.length > 0 + const addressesQuery = queryAddresses + ? `where: { id_in: $addresses }` + : 'block: {number_gte: $blockNumber}, first: 1000' + const addressesParam = queryAddresses ? `$addresses: [String]!` : '$blockNumber: Int!' const query = ` - query getVestings($addresses: [String]!) { - vestings(where: { id_in: $addresses }){ + query getVestings(${addressesParam}) { + vestings(${addressesQuery}){ id version duration @@ -122,14 +129,15 @@ export class VestingsSubgraph { } } ` - - const variables = { addresses } + const variables = queryAddresses + ? { addresses: addresses.map((address) => address.toLowerCase()) } + : { blockNumber: OLDEST_INDEXED_BLOCK } const response = await fetch(this.queryEndpoint, { method: 'post', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, - variables: variables, + variables, }), }) diff --git a/src/entities/Proposal/model.ts b/src/entities/Proposal/model.ts index b3400263f..e768b68c5 100644 --- a/src/entities/Proposal/model.ts +++ b/src/entities/Proposal/model.ts @@ -469,32 +469,6 @@ export default class ProposalModel extends Model { return proposals.map(this.parse) } - 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 prop.*, - proj.id as project_id, - COALESCE(json_agg(DISTINCT to_jsonb(pe.*)) FILTER (WHERE pe.id IS NOT NULL), '[]') as personnel, - COALESCE(array_agg(co.address) FILTER (WHERE co.address IS NOT NULL), '{}') AS coauthors - FROM ${table(ProposalModel)} prop - LEFT OUTER JOIN ${table(ProjectModel)} proj on prop.id = proj.proposal_id - LEFT JOIN ${table(PersonnelModel)} pe ON proj.id = pe.project_id AND pe.deleted = false - LEFT JOIN ${table(CoauthorModel)} co ON prop.id = co.proposal_id AND co.status = ${CoauthorStatus.APPROVED} - WHERE prop."deleted" = FALSE - AND prop."type" IN (${join(types)}) - AND prop."status" IN (${join(status)}) - GROUP BY prop.id, proj.id - ORDER BY prop."created_at" DESC - ` - ) - - return proposals.map(this.parseProposalWithProject) - } - private static parseTimeframe(timeFrame?: string | null) { const date = Time.utc() switch (timeFrame) { diff --git a/src/entities/Proposal/types.ts b/src/entities/Proposal/types.ts index 773df6015..e88edd59c 100644 --- a/src/entities/Proposal/types.ts +++ b/src/entities/Proposal/types.ts @@ -803,27 +803,7 @@ export type ProjectFunding = { vesting?: Vesting } -export type ProposalProject = { - id: string - project_id?: string | null - status: ProjectStatus - title: string - user: string - coAuthors?: string[] - personnel: PersonnelAttributes[] - size: number - type: ProposalType - about: string - created_at: number - updated_at: number - configuration: { - category: ProposalGrantCategory - tier: string - } - funding?: ProjectFunding -} - -export type ProposalProjectWithUpdate = ProposalProject & { +export type LatestUpdate = { update?: IndexedUpdate | null update_timestamp?: number } diff --git a/src/entities/Proposal/utils.ts b/src/entities/Proposal/utils.ts index 1114ba42f..a49138cea 100644 --- a/src/entities/Proposal/utils.ts +++ b/src/entities/Proposal/utils.ts @@ -34,9 +34,6 @@ export const SITEMAP_ITEMS_PER_PAGE = 100 export const DEFAULT_CHOICES = ['yes', 'no', 'abstain'] export const REGEX_NAME = new RegExp(`^([a-zA-Z0-9]){${MIN_NAME_SIZE},${MAX_NAME_SIZE}}$`) -//TODO: avoid manually calculating cliff, use subgraph or contract method instead -export const CLIFF_PERIOD_IN_DAYS = 29 - export function formatBalance(value: number | bigint) { return numeral(value).format('0,0') } diff --git a/src/entities/Updates/types.ts b/src/entities/Updates/types.ts index c09d4f44c..5abc76070 100644 --- a/src/entities/Updates/types.ts +++ b/src/entities/Updates/types.ts @@ -34,7 +34,7 @@ export type UpdateAttributes = Partial & discourse_topic_slug?: string } -export type IndexedUpdate = UpdateAttributes & { +export type IndexedUpdate = Partial & { index: number } diff --git a/src/models/Project.ts b/src/models/Project.ts index 3d5bfdfcb..2636efd73 100644 --- a/src/models/Project.ts +++ b/src/models/Project.ts @@ -1,5 +1,5 @@ import { Model } from 'decentraland-gatsby/dist/entities/Database/model' -import { SQL, table } from 'decentraland-gatsby/dist/entities/Database/utils' +import { SQL, conditional, table } from 'decentraland-gatsby/dist/entities/Database/utils' import isEthereumAddress from 'validator/lib/isEthereumAddress' import isUUID from 'validator/lib/isUUID' @@ -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 { LatestUpdate, ProjectFunding, ProposalAttributes, ProposalType } 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' @@ -35,6 +37,30 @@ export type Project = ProjectAttributes & { funding?: ProjectFunding } +export type ProjectInList = Pick & + Pick & { + latest_update?: LatestUpdate + created_at: number + updated_at: number + } + +type ProposalDataForProject = Pick< + ProposalAttributes, + 'enacting_tx' | 'enacted_description' | 'vesting_addresses' | 'type' | 'configuration' +> & { + proposal_created_at: Date + proposal_updated_at: Date +} + +export type ProjectQueryResult = Pick & + ProposalDataForProject & { updates?: UpdateAttributes[] } + +export type UserProject = Pick< + Project, + 'id' | 'proposal_id' | 'status' | 'title' | 'author' | 'personnel' | 'coauthors' | 'funding' +> & + ProposalDataForProject + export default class ProjectModel extends Model { static tableName = 'projects' static withTimestamps = false @@ -96,4 +122,96 @@ 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(from?: Date, to?: Date): Promise { + const query = SQL` + SELECT + pr.id, + pr.proposal_id, + pr.status, + pr.title, + p.type, + p.enacting_tx, + p.enacted_description, + p.configuration, + p.user as author, + p.vesting_addresses, + p.created_at as proposal_created_at, + p.updated_at as proposal_updated_at, + COALESCE(json_agg(DISTINCT to_jsonb(ordered_updates.*)) FILTER (WHERE ordered_updates.id IS NOT NULL), '[]') as updates + FROM ${table(ProjectModel)} pr + JOIN ${table(ProposalModel)} p ON pr.proposal_id = p.id + LEFT JOIN (SELECT * FROM ${table(UpdateModel)} up ORDER BY up.created_at DESC) ordered_updates + ON pr.id = ordered_updates.project_id + WHERE 1=1 + ${conditional(!!from, SQL`AND pr.created_at >= ${from}`)} + ${conditional(!!to, SQL`AND pr.created_at <= ${to}`)} + GROUP BY + pr.id, + pr.proposal_id, + pr.status, + pr.title, + p.created_at, + p.updated_at, + p.type, + p.enacting_tx, + p.enacted_description, + p.configuration, + p.user, + p.vesting_addresses, + p.updated_at + ORDER BY p.created_at DESC; + ` + + const result = await this.namedQuery(`get_projects`, query) + return result || [] + } + + static async getUserProjects(userAddress: string): Promise { + const query = SQL` + SELECT + pr.id, + pr.proposal_id, + pr.status, + pr.title, + COALESCE(json_agg(DISTINCT to_jsonb(pe.*)) FILTER (WHERE pe.id IS NOT NULL), '[]') as personnel, + COALESCE(array_agg(co.address) FILTER (WHERE co.address IS NOT NULL), '{}') AS coauthors, + p.enacting_tx, + p.enacted_description, + p.vesting_addresses, + p.type, + p.configuration, + p.user as author, + p.created_at as proposal_created_at, + 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(CoauthorModel)} co ON pr.proposal_id = co.proposal_id AND co.status = ${ + CoauthorStatus.APPROVED + } + WHERE + p.type = ${ProposalType.Grant} AND + (lower(p.user) = lower(${userAddress}) OR + lower(co.address) = lower(${userAddress}) OR + lower(pe.address) = lower(${userAddress})) + GROUP BY + pr.id, + pr.proposal_id, + pr.status, + pr.title, + p.enacting_tx, + p.enacted_description, + p.vesting_addresses, + p.type, + p.configuration, + p.user, + p.created_at, + p.updated_at + ORDER BY p.created_at DESC; + ` + + const result = await this.namedQuery(`get_user_projects`, query) + return result || [] + } } diff --git a/src/routes/project.ts b/src/routes/project.ts index a7cb44201..d039cbb02 100644 --- a/src/routes/project.ts +++ b/src/routes/project.ts @@ -9,55 +9,59 @@ import { ProjectLinkInCreationSchema, ProjectMilestoneInCreationSchema, } from '../entities/Project/types' -import { ProposalProjectWithUpdate } from '../entities/Proposal/types' import PersonnelModel, { PersonnelAttributes } from '../models/Personnel' +import { ProjectInList, UserProject } 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 { isValidDate, validateCanEditProject, validateId } from '../utils/validations' +import { ErrorCategory } from '../utils/errorCategories' +import { isValidDate, validateAddress, validateCanEditProject, validateId } from '../utils/validations' export default routes((route) => { const withAuth = auth() - route.get('/projects', handleJSON(getProjects)) + route.get('/projects', handleJSON(getProjectsList)) + route.get('/projects/pitches-total', handleJSON(getOpenPitchesTotal)) + route.get('/projects/tenders-total', handleJSON(getOpenTendersTotal)) 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/user/:address', handleAPI(getProjectsByUser)) route.get('/projects/:project', handleAPI(getProject)) - route.get('/projects/pitches-total', handleJSON(getOpenPitchesTotal)) - route.get('/projects/tenders-total', handleJSON(getOpenTendersTotal)) + route.delete('/projects/links/:link_id', withAuth, handleAPI(deleteLink)) + route.delete('/projects/personnel/:personnel_id', withAuth, handleAPI(deletePersonnel)) + route.delete('/projects/milestones/:milestone_id', withAuth, handleAPI(deleteMilestone)) }) -function filterProjectsByDate( - projects: ProposalProjectWithUpdate[], - from?: Date, - to?: Date -): ProposalProjectWithUpdate[] { +function filterProjectsByDate(projects: ProjectInList[], from?: Date, to?: Date): ProjectInList[] { return projects.filter((project) => { const createdAt = new Date(project.created_at) return (!from || createdAt >= from) && (!to || createdAt < to) }) } -async function getProjects(req: Request) { - const from = isValidDate(req.query.from as string) ? new Date(req.query.from as string) : undefined - const to = isValidDate(req.query.to as string) ? new Date(req.query.to as string) : undefined - - if (from && to && from > to) { - throw new RequestError('Invalid date range', RequestError.BadRequest) - } - - const cacheKey = `projects` - const cachedProjects = CacheService.get(cacheKey) - if (cachedProjects) { - return { data: filterProjectsByDate(cachedProjects, from, to) } +async function getProjectsList(req: Request) { + try { + const from = isValidDate(req.query.from as string) ? new Date(req.query.from as string) : undefined + const to = isValidDate(req.query.to as string) ? new Date(req.query.to as string) : undefined + + if (from && to && from > to) { + throw new RequestError('Invalid date range', RequestError.BadRequest) + } + + const cacheKey = `projects` + const cachedProjects = CacheService.get(cacheKey) + if (cachedProjects) { + return { data: filterProjectsByDate(cachedProjects, from, to) } + } + const projects = await ProjectService.getProjects() + CacheService.set(cacheKey, projects, TTL_1_HS) + return { data: filterProjectsByDate(projects, from, to) } + } catch (error) { + ErrorService.report('Error fetching projects', { error, category: ErrorCategory.Project }) + throw new RequestError(`Unable to load projects`, RequestError.InternalServerError) } - const projects = await ProjectService.getProposalProjects() - CacheService.set(cacheKey, projects, TTL_1_HS) - return { data: filterProjectsByDate(projects, from, to) } } async function getProject(req: Request<{ project: string }>) { @@ -69,6 +73,16 @@ async function getProject(req: Request<{ project: string }>) { } } +async function getProjectsByUser(req: Request): Promise<{ data: UserProject[]; total: number }> { + const address = validateAddress(req.params.address) + + const projects = await ProjectService.getUserProjects(address) + return { + data: projects, + total: projects.length, + } +} + async function getOpenPitchesTotal() { return await ProjectService.getOpenPitchesTotal() } diff --git a/src/routes/proposal.ts b/src/routes/proposal.ts index 84522d4cb..e70049ec8 100644 --- a/src/routes/proposal.ts +++ b/src/routes/proposal.ts @@ -10,8 +10,6 @@ import isUUID from 'validator/lib/isUUID' import { SnapshotGraphql } from '../clients/SnapshotGraphql' import { BidRequest, BidRequestSchema } from '../entities/Bid/types' -import CoauthorModel from '../entities/Coauthor/model' -import { CoauthorStatus } from '../entities/Coauthor/types' import { hasOpenSlots } from '../entities/Committee/utils' import { GrantRequest, getGrantRequestSchema, toGrantSubtype } from '../entities/Grant/types' import { @@ -75,7 +73,6 @@ import { toSortingOrder, } from '../entities/Proposal/utils' import { SNAPSHOT_DURATION } from '../entities/Snapshot/constants' -import { isSameAddress } from '../entities/Snapshot/utils' import BidService from '../services/BidService' import { DiscourseService } from '../services/DiscourseService' import { ErrorService } from '../services/ErrorService' @@ -105,7 +102,6 @@ export default routes((route) => { route.post('/proposals/bid', withAuth, handleAPI(createProposalBid)) route.post('/proposals/hiring', withAuth, handleAPI(createProposalHiring)) route.get('/proposals/priority/:address?', handleJSON(getPriorityProposals)) - route.get('/proposals/grants/:address', handleAPI(getProjectsByUser)) //TODO: move to project routes route.get('/proposals/:proposal', handleAPI(getProposalWithProject)) route.patch('/proposals/:proposal', withAuth, handleAPI(updateProposalStatus)) route.delete('/proposals/:proposal', withAuth, handleAPI(removeProposal)) @@ -591,27 +587,6 @@ async function validateSubmissionThreshold(user: string, submissionThreshold?: s } } -async function getProjectsByUser(req: Request) { - const address = validateAddress(req.params.address) - - const coauthoring = await CoauthorModel.findProposals(address, CoauthorStatus.APPROVED) - const coauthoringProposalIds = new Set(coauthoring.map((coauthoringAttributes) => coauthoringAttributes.proposal_id)) - - const projects = await ProjectService.getProposalProjects() - const filteredProposalProjectsWithUpdates = projects.filter( - (project) => - project.type === ProposalType.Grant && - (isSameAddress(project.user, address) || - coauthoringProposalIds.has(project.id) || - project.personnel.some((person) => isSameAddress(person.address, address))) - ) - - return { - data: filteredProposalProjectsWithUpdates, - total: filteredProposalProjectsWithUpdates.length, - } -} - async function checkImage(req: Request) { const imageUrl = req.query.url as string const allowedImageTypes = new Set(['image/bmp', 'image/jpeg', 'image/png', 'image/webp']) diff --git a/src/services/ProjectService.ts b/src/services/ProjectService.ts index b7e825d77..8a9de5700 100644 --- a/src/services/ProjectService.ts +++ b/src/services/ProjectService.ts @@ -1,109 +1,144 @@ 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' import { GRANT_PROPOSAL_DURATION_IN_SECONDS } from '../entities/Grant/constants' -import { GrantRequest, ProjectStatus, VestingStatus } from '../entities/Grant/types' +import { GrantRequest, ProjectStatus } 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, + LatestUpdate, ProposalAttributes, - ProposalProjectWithUpdate, ProposalStatus, ProposalType, } from '../entities/Proposal/types' import { DEFAULT_CHOICES, asNumber, getProposalEndDate, isProjectProposal } from '../entities/Proposal/utils' -import UpdateModel from '../entities/Updates/model' -import { IndexedUpdate, UpdateAttributes } from '../entities/Updates/types' +import { isSameAddress } from '../entities/Snapshot/utils' +import { 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, + ProjectInList, + ProjectQueryResult, + UserProject, +} 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 { getProjectFunding, getProjectStatus, toGovernanceProjectStatus } from '../utils/projects' import { BudgetService } from './BudgetService' import { ErrorService } from './ErrorService' import { ProposalInCreation } from './ProposalService' import { VestingService } from './VestingService' -function newestVestingFirst(a: TransparencyVesting, b: TransparencyVesting): number { - const startDateSort = new Date(b.vesting_start_at).getTime() - new Date(a.vesting_start_at).getTime() - const finishDateSort = new Date(b.vesting_finish_at).getTime() - new Date(a.vesting_finish_at).getTime() +function sortByNewestVestingFirst(a: ProjectInList, b: ProjectInList): number { + const startDateSort = + new Date(b.funding?.vesting?.start_at || b.updated_at).getTime() - + new Date(a.funding?.vesting?.start_at || a.updated_at).getTime() + const finishDateSort = + new Date(b.funding?.vesting?.finish_at || b.updated_at).getTime() - + new Date(a.funding?.vesting?.finish_at || a.updated_at).getTime() return startDateSort !== 0 ? startDateSort : finishDateSort } export class ProjectService { - public static async getProposalProjects() { - const proposalWithProjects = await ProposalModel.getProjectList() + public static async getProjects(): Promise { + const projectsQueryResults = await ProjectModel.getProjectsWithUpdates() const vestings = await VestingService.getAllVestings() - const projects: ProposalProjectWithUpdate[] = [] - - await Promise.all( - proposalWithProjects.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 === VestingStatus.InProgress || vesting.vesting_status === VestingStatus.Finished - ) || proposalVestings[0] //TODO: replace transparency vestings for vestings subgraph - const project = createProposalProject(proposal, prioritizedVesting) - - try { - const update = await this.getProjectLatestUpdate(project.id) - const projectWithUpdate: ProposalProjectWithUpdate = { - ...project, - ...this.getUpdateData(update), - } - - return projects.push(projectWithUpdate) - } catch (error) { - logger.error(`Failed to fetch grant update data from proposal ${project.id}`, formatError(error as Error)) - } - } catch (error) { - if (isProdEnv()) { - logger.error(`Failed to get data for ${proposal.id}`, formatError(error as Error)) - } + const updatedProjects = this.getProjectInList(projectsQueryResults, vestings) + return updatedProjects.sort(sortByNewestVestingFirst) + } + + private static getProjectInList( + projectQueryResults: ProjectQueryResult[], + latestVestings: VestingWithLogs[] + ): ProjectInList[] { + return ( + projectQueryResults.map((project) => { + const { funding, status } = this.getUpdatedFundingAndStatus(project, latestVestings) + const { tier, category, size, funding: proposal_funding } = project.configuration + const { updates, proposal_updated_at, proposal_created_at, ...rest } = project + return { + ...rest, + created_at: proposal_created_at.getTime(), + updated_at: proposal_updated_at.getTime(), + configuration: { + size: size || proposal_funding, + tier, + category: category || project.type, + }, + status, + funding, + latest_update: this.getProjectLatestUpdate(updates ?? []), } - }) + }) || [] ) + } - return projects + private static getUpdatedFundingAndStatus( + project: ProjectQueryResult | UserProject, + latestVestings: VestingWithLogs[] + ) { + const latestVestingAddress = project.vesting_addresses[project.vesting_addresses.length - 1] + const vestingWithLogs = latestVestings.find((vesting) => isSameAddress(vesting.address, latestVestingAddress)) + const funding = getProjectFunding(project, vestingWithLogs) + const status = getProjectStatus(project, vestingWithLogs) + return { funding, status } } - private static getUpdateData(update: (UpdateAttributes & { index: number }) | null) { - return { - update, - update_timestamp: update?.completion_date ? Time(update?.completion_date).unix() : 0, - } + public static async getUserProjects(address: string): Promise { + const userProjects = await ProjectModel.getUserProjects(address) + const vestings = await VestingService.getAllVestings() + return this.getUserProjectsWithFunding(userProjects, vestings) + } + + private static getUserProjectsWithFunding( + userProjects: UserProject[], + latestVestings: VestingWithLogs[] + ): UserProject[] { + return ( + userProjects.map((project) => { + const { funding, status } = this.getUpdatedFundingAndStatus(project, latestVestings) + const { tier, category, size, funding: proposal_funding } = project.configuration + return { + ...project, + configuration: { + size: size || proposal_funding, + tier, + category: category || project.type, + }, + status, + funding, + } + }) || [] + ) } - private static async getProjectLatestUpdate(proposalId: string): Promise { - const updates = await UpdateModel.find({ proposal_id: proposalId }, { - created_at: 'desc', - } as never) + private static getProjectLatestUpdate(updates: UpdateAttributes[]): LatestUpdate { if (!updates || updates.length === 0) { - return null + return { update_timestamp: 0 } } const publicUpdates = getPublicUpdates(updates) const currentUpdate = publicUpdates[0] if (!currentUpdate) { - return null + return { update_timestamp: 0 } + } + const { id, introduction, status, health, completion_date } = currentUpdate + return { + update: { id, introduction, status, health, completion_date, index: publicUpdates.length }, + update_timestamp: currentUpdate?.completion_date ? Time(currentUpdate?.completion_date).unix() : 0, } - - return { ...currentUpdate, index: publicUpdates.length } } public static async getGrantInCreation(grantRequest: GrantRequest, user: string) { diff --git a/src/services/VestingService.ts b/src/services/VestingService.ts index d936872a4..8e107b070 100644 --- a/src/services/VestingService.ts +++ b/src/services/VestingService.ts @@ -1,4 +1,3 @@ -import { Transparency, TransparencyVesting } from '../clients/Transparency' import { Vesting, VestingLog, @@ -16,26 +15,29 @@ import { VestingStatus } from '../entities/Grant/types' import { ContractVersion, TopicsByVersion } from '../utils/contracts/vesting' import { ErrorCategory } from '../utils/errorCategories' -import CacheService, { TTL_24_HS } from './CacheService' +import CacheService, { TTL_1_HS } from './CacheService' import { ErrorService } from './ErrorService' export class VestingService { - static async getAllVestings() { - const cacheKey = `vesting-data` + static async getAllVestings(): Promise { + const cacheKey = `vesting-subgraph-data` - const cachedData = CacheService.get(cacheKey) + const cachedData = CacheService.get(cacheKey) if (cachedData) { return cachedData } - - const transparencyVestings = await Transparency.getVestings() - CacheService.set(cacheKey, transparencyVestings, TTL_24_HS) - return transparencyVestings + const vestingsData = await VestingsSubgraph.get().getVestings() + const sortedVestings = vestingsData + .map((data) => this.parseSubgraphVesting(data)) + .sort((a, b) => this.sortVestingsByDate(a, b)) + CacheService.set(cacheKey, sortedVestings, TTL_1_HS) + return sortedVestings } static async getVestings(addresses: string[]): Promise { const vestingsData = await VestingsSubgraph.get().getVestings(addresses) - return vestingsData.map(this.parseSubgraphVesting).sort(this.sortVestingsByDate) + const sortedVestings = vestingsData.map(this.parseSubgraphVesting).sort(this.sortVestingsByDate) + return sortedVestings } static async getVestingWithLogs( diff --git a/src/utils/projects.ts b/src/utils/projects.ts index c8a18e6bd..e9302069e 100644 --- a/src/utils/projects.ts +++ b/src/utils/projects.ts @@ -1,10 +1,7 @@ -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 Time from './date/Time' +import { ProjectFunding } from '../entities/Proposal/types' +import { ProjectQueryResult, UserProject } from '../models/Project' export function getHighBudgetVpThreshold(budget: number) { return 1200000 + budget * 40 @@ -25,93 +22,39 @@ export function toGovernanceProjectStatus(status: VestingStatus) { } } -function getFunding(proposal: ProposalAttributes, transparencyVesting?: TransparencyVesting): ProjectFunding { - if (proposal.enacting_tx) { +export function getProjectFunding( + project: ProjectQueryResult | UserProject, + vesting: Vesting | undefined +): ProjectFunding { + if (project.enacting_tx) { // one time payment return { - enacted_at: proposal.updated_at.toISOString(), + enacted_at: project.proposal_updated_at.toISOString(), one_time_payment: { - enacting_tx: proposal.enacting_tx, + enacting_tx: project.enacting_tx, }, } } - if (!transparencyVesting) { + if (!vesting) { return {} } - return { - enacted_at: transparencyVesting.vesting_start_at, - vesting: toVesting(transparencyVesting), + enacted_at: vesting.start_at, + vesting, } } -function getProjectStatus(proposal: ProposalAttributes, vesting?: TransparencyVesting) { - const legacyCondition = !vesting && proposal.enacted_description - if (proposal.enacting_tx || legacyCondition) { +export function getProjectStatus( + project: ProjectQueryResult | UserProject, + vesting: VestingWithLogs | undefined +): ProjectStatus { + const legacyCondition = project.enacting_tx || (!vesting && project.enacted_description) + if (legacyCondition) { return ProjectStatus.Finished } - if (!vesting) { return ProjectStatus.Pending } - - const { vesting_status } = vesting - - return toGovernanceProjectStatus(vesting_status) -} - -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, - personnel: proposal.personnel, - coAuthors: proposal.coAuthors, - 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, - }, - funding, - } -} - -//TODO: stop using transparency vestings -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, - cliff: Time.unix(Number(vesting_start_at)).add(CLIFF_PERIOD_IN_DAYS, 'day').getTime().toString(), - vestedPerPeriod: [], - } - - return vesting + return toGovernanceProjectStatus(vesting.status) }