diff --git a/src/back/models/Personnel.ts b/src/back/models/Personnel.ts index 872e5e446..793eefc16 100644 --- a/src/back/models/Personnel.ts +++ b/src/back/models/Personnel.ts @@ -1,4 +1,6 @@ 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' @@ -11,6 +13,21 @@ 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 = 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 { static tableName = 'personnel' static withTimestamps = false diff --git a/src/back/routes/project.ts b/src/back/routes/project.ts index 3934e14c7..5bb8c5cdd 100644 --- a/src/back/routes/project.ts +++ b/src/back/routes/project.ts @@ -1,3 +1,4 @@ +import { WithAuth, auth } from 'decentraland-gatsby/dist/entities/Auth/middleware' import RequestError from 'decentraland-gatsby/dist/entities/Route/error' import handleAPI, { handleJSON } from 'decentraland-gatsby/dist/entities/Route/handle' import routes from 'decentraland-gatsby/dist/entities/Route/routes' @@ -5,10 +6,14 @@ import { Request } from 'express' import CacheService, { TTL_1_HS } from '../../services/CacheService' import { ProjectService } from '../../services/ProjectService' +import { isProjectAuthorOrCoauthor } from '../../utils/projects' +import { PersonnelAttributes, PersonnelInCreationSchema } from '../models/Personnel' import { isValidDate, validateId } from '../utils/validations' export default routes((route) => { + const withAuth = auth() route.get('/projects', handleJSON(getProjects)) + route.post('/projects/personnel/', withAuth, handleAPI(addPersonnel)) route.get('/projects/:project', handleAPI(getProject)) route.get('/projects/pitches-total', handleJSON(getOpenPitchesTotal)) route.get('/projects/tenders-total', handleJSON(getOpenTendersTotal)) @@ -60,3 +65,21 @@ async function getOpenPitchesTotal() { async function getOpenTendersTotal() { return await ProjectService.getOpenTendersTotal() } + +async function addPersonnel(req: WithAuth): Promise { + 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 parsedPersonnel = PersonnelInCreationSchema.safeParse(personnel) + if (!parsedPersonnel.success) { + throw new RequestError(`Invalid personnel: ${parsedPersonnel.error.message}`, RequestError.BadRequest) + } + return await ProjectService.addPersonnel(parsedPersonnel.data, user) +} diff --git a/src/entities/Grant/types.ts b/src/entities/Grant/types.ts index c92c6a0ba..e8b9ba170 100644 --- a/src/entities/Grant/types.ts +++ b/src/entities/Grant/types.ts @@ -439,7 +439,7 @@ export type GrantRequestDueDiligence = { export type TeamMember = { name: string - address?: string + address?: string | null role: string about: string relevantLink?: string diff --git a/src/services/ProjectService.ts b/src/services/ProjectService.ts index 203ff4c5f..902f27b70 100644 --- a/src/services/ProjectService.ts +++ b/src/services/ProjectService.ts @@ -1,6 +1,6 @@ import crypto from 'crypto' -import PersonnelModel, { PersonnelAttributes } from '../back/models/Personnel' +import PersonnelModel, { PersonnelAttributes, PersonnelInCreation } from '../back/models/Personnel' import ProjectModel, { ProjectAttributes } from '../back/models/Project' import ProjectMilestoneModel, { ProjectMilestone, ProjectMilestoneStatus } from '../back/models/ProjectMilestone' import { TransparencyVesting } from '../clients/Transparency' @@ -234,4 +234,16 @@ export class ProjectService { 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, + }) + } } diff --git a/src/utils/projects.ts b/src/utils/projects.ts index ee5e5a512..d66e90a0d 100644 --- a/src/utils/projects.ts +++ b/src/utils/projects.ts @@ -1,6 +1,8 @@ +import { Project } from '../back/models/Project' import { TransparencyVesting } from '../clients/Transparency' import { ProjectStatus, TransparencyProjectStatus } from '../entities/Grant/types' import { ProposalAttributes, ProposalProject } from '../entities/Proposal/types' +import { isSameAddress } from '../entities/Snapshot/utils' import Time from './date/Time' @@ -80,3 +82,7 @@ export function createProposalProject(proposal: ProposalAttributes, vesting?: Tr ...vestingData, } } + +export function isProjectAuthorOrCoauthor(user: string, project: Project) { + return isSameAddress(user, project.author) || !!project.coauthors?.some((coauthor) => isSameAddress(user, coauthor)) +}