Skip to content

Commit

Permalink
chore: project links (#1823)
Browse files Browse the repository at this point in the history
* chore: create project links

* fix: add/delete link endpoints

* refactor: project editor validation

* refactor: remove unused method
  • Loading branch information
1emu committed Jun 3, 2024
1 parent b88603e commit 3ebadc6
Show file tree
Hide file tree
Showing 6 changed files with 97 additions and 42 deletions.
17 changes: 0 additions & 17 deletions src/back/models/Personnel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { Model } from 'decentraland-gatsby/dist/entities/Database/model'
import isEthereumAddress from 'validator/lib/isEthereumAddress'
import { ZodSchema, z } from 'zod'

import { TeamMember } from '../../entities/Grant/types'

Expand All @@ -13,21 +11,6 @@ export type PersonnelAttributes = TeamMember & {
created_at: Date
}

const addressCheck = (data: string) => !data || (!!data && isEthereumAddress(data))

export type PersonnelInCreation = Pick<
PersonnelAttributes,
'name' | 'address' | 'role' | 'about' | 'relevantLink' | 'project_id'
>
export const PersonnelInCreationSchema: ZodSchema<PersonnelInCreation> = 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 default class PersonnelModel extends Model<PersonnelAttributes> {
static tableName = 'personnel'
static withTimestamps = false
Expand Down
25 changes: 25 additions & 0 deletions src/back/models/Project.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Model } from 'decentraland-gatsby/dist/entities/Database/model'
import { SQL, 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'
Expand Down Expand Up @@ -68,4 +69,28 @@ export default class ProjectModel extends Model<ProjectAttributes> {

return result[0]
}

static async isAuthorOrCoauthor(user: string, projectId: string): Promise<boolean> {
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
}
}
60 changes: 43 additions & 17 deletions src/back/routes/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,20 @@ import handleAPI, { handleJSON } from 'decentraland-gatsby/dist/entities/Route/h
import routes from 'decentraland-gatsby/dist/entities/Route/routes'
import { Request } from 'express'

import { PersonnelInCreationSchema } from '../../entities/Project/types'
import { PersonnelInCreationSchema, ProjectLinkInCreationSchema } from '../../entities/Project/types'
import CacheService, { TTL_1_HS } from '../../services/CacheService'
import { ProjectService } from '../../services/ProjectService'
import { isProjectAuthorOrCoauthor } from '../../utils/projects'
import PersonnelModel, { PersonnelAttributes } from '../models/Personnel'
import { isValidDate, validateId } from '../utils/validations'
import ProjectLinkModel, { ProjectLink } from '../models/ProjectLink'
import { isValidDate, validateAddress, 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.get('/projects/:project', handleAPI(getProject))
route.get('/projects/pitches-total', handleJSON(getOpenPitchesTotal))
route.get('/projects/tenders-total', handleJSON(getOpenTendersTotal))
Expand Down Expand Up @@ -68,21 +70,25 @@ async function getOpenTendersTotal() {
return await ProjectService.getOpenTendersTotal()
}

async function validateCanEditProject(user: string, projectId: string) {
validateId(projectId)
validateAddress(user)
const isValidEditor = await ProjectService.isAuthorOrCoauthor(user, projectId)
if (!isValidEditor) {
throw new RequestError("Only the project's authors and coauthors can edit the project", RequestError.Unauthorized)
}
}

async function addPersonnel(req: WithAuth): Promise<PersonnelAttributes> {
const user = req.auth!
const { personnel } = req.body
validateId(personnel.project_id)
const project = await ProjectService.getProject(personnel.project_id)
if (!project) {
throw new RequestError(`Project "${personnel.project_id}" not found`, RequestError.NotFound)
}
if (!isProjectAuthorOrCoauthor(user, project)) {
throw new RequestError("Only the project's authors and coauthors can create personnel", RequestError.Unauthorized)
}
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)
}

Expand All @@ -94,12 +100,32 @@ async function deletePersonnel(req: WithAuth<Request<{ personnel_id: string }>>)
if (!personnel) {
throw new RequestError(`Personnel "${personnel_id}" not found`, RequestError.NotFound)
}
const project = await ProjectService.getProject(personnel.project_id)
if (!project) {
throw new RequestError(`Project "${personnel.project_id}" not found`, RequestError.NotFound)
await validateCanEditProject(user, personnel.project_id)

return await ProjectService.deletePersonnel(personnel_id, user)
}

async function addLink(req: WithAuth): Promise<ProjectLink> {
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)
}
if (!isProjectAuthorOrCoauthor(user, project)) {
throw new RequestError("Only the project's authors and coauthors can delete personnel", RequestError.Unauthorized)

return await ProjectService.addLink(parsedLink.data, user)
}

async function deleteLink(req: WithAuth<Request<{ link_id: string }>>): Promise<string | null> {
const user = req.auth!
const link_id = req.params.link_id
validateId(link_id)
const projectLink = await ProjectLinkModel.findOne<ProjectLink>(link_id)
if (!projectLink) {
throw new RequestError(`Link "${link_id}" not found`, RequestError.NotFound)
}
return await ProjectService.deletePersonnel(personnel_id, user)
await validateCanEditProject(user, projectLink.project_id)

return await ProjectService.deleteLink(link_id)
}
8 changes: 8 additions & 0 deletions src/entities/Project/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import isEthereumAddress from 'validator/lib/isEthereumAddress'
import { ZodSchema, z } from 'zod'

import { PersonnelAttributes } from '../../back/models/Personnel'
import { ProjectLink } from '../../back/models/ProjectLink'

const addressCheck = (data: string) => !data || (!!data && isEthereumAddress(data))

Expand All @@ -17,3 +18,10 @@ export const PersonnelInCreationSchema: ZodSchema<PersonnelInCreation> = z.objec
relevantLink: z.string().min(0).max(200).url().optional().or(z.literal('')),
project_id: z.string().min(0),
})

export type ProjectLinkInCreation = Pick<ProjectLink, 'label' | 'url' | 'project_id'>
export const ProjectLinkInCreationSchema: ZodSchema<ProjectLinkInCreation> = z.object({
label: z.string().min(1).max(80),
url: z.string().min(0).max(200).url(),
project_id: z.string().min(0),
})
23 changes: 21 additions & 2 deletions src/services/ProjectService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import crypto from 'crypto'

import PersonnelModel, { PersonnelAttributes } from '../back/models/Personnel'
import ProjectModel, { 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 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, TransparencyProjectStatus } from '../entities/Grant/types'
import { PersonnelInCreation } from '../entities/Project/types'
import { PersonnelInCreation, ProjectLinkInCreation } from '../entities/Project/types'
import ProposalModel from '../entities/Proposal/model'
import { ProposalWithOutcome } from '../entities/Proposal/outcome'
import {
Expand Down Expand Up @@ -236,7 +237,7 @@ export class ProjectService {

static async addPersonnel(newPersonnel: PersonnelInCreation, user?: string) {
const { address } = newPersonnel
return await PersonnelModel.create({
return await PersonnelModel.create<PersonnelAttributes>({
...newPersonnel,
address: address && address?.length > 0 ? address : null,
id: crypto.randomUUID(),
Expand All @@ -253,4 +254,22 @@ export class ProjectService {
)
return !!result && result.rowCount === 1 ? personnel_id : null
}

static async addLink(newLink: ProjectLinkInCreation, user: string) {
return await ProjectLinkModel.create<ProjectLink>({
...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 isAuthorOrCoauthor(user: string, projectId: string) {
return await ProjectModel.isAuthorOrCoauthor(user, projectId)
}
}
6 changes: 0 additions & 6 deletions src/utils/projects.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { Project } from '../back/models/Project'
import { TransparencyVesting } from '../clients/Transparency'
import { ProjectStatus, TransparencyProjectStatus } from '../entities/Grant/types'
import { ProposalAttributes, ProposalProject, ProposalWithProject } from '../entities/Proposal/types'
import { isSameAddress } from '../entities/Snapshot/utils'

import Time from './date/Time'

Expand Down Expand Up @@ -83,7 +81,3 @@ export function createProposalProject(proposal: ProposalWithProject, vesting?: T
...vestingData,
}
}

export function isProjectAuthorOrCoauthor(user: string, project: Project) {
return isSameAddress(user, project.author) || !!project.coauthors?.some((coauthor) => isSameAddress(user, coauthor))
}

0 comments on commit 3ebadc6

Please sign in to comment.