Skip to content

Commit

Permalink
feat(api): Add feature to fork projects (#239)
Browse files Browse the repository at this point in the history
  • Loading branch information
rajdip-b committed May 24, 2024
1 parent 079186b commit 3bab653
Show file tree
Hide file tree
Showing 14 changed files with 1,586 additions and 141 deletions.
8 changes: 4 additions & 4 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
"start": "node dist/main",
"dev": "cross-env NODE_ENV=dev nest start --watch",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"db:generate-types": "pnpm dlx prisma generate --schema=src/prisma/schema.prisma",
"db:generate-migrations": "pnpx dotenv-cli -e ../../.env -- pnpx prisma migrate dev --create-only --skip-seed --schema=src/prisma/schema.prisma",
"db:deploy-migrations": "pnpx dotenv-cli -e ../../.env -- pnpx prisma migrate deploy --schema=src/prisma/schema.prisma",
Expand All @@ -18,8 +17,9 @@
"db:reset": "pnpx dotenv-cli -e ../../.env -- pnpm dlx prisma migrate reset --force --schema=src/prisma/schema.prisma",
"sourcemaps": "sentry-cli sourcemaps inject ./dist && sentry-cli sourcemaps upload ./dist || echo 'Failed to upload source maps to Sentry'",
"e2e:prepare": "cd ../../ && docker compose down && docker compose -f docker-compose-test.yml up -d && cd apps/api && pnpm db:generate-types && cross-env NODE_ENV='e2e' DATABASE_URL='postgresql://prisma:prisma@localhost:5432/tests' pnpm run db:deploy-migrations",
"e2e": "pnpm run e2e:prepare && cross-env NODE_ENV='e2e' DATABASE_URL='postgresql://prisma:prisma@localhost:5432/tests' turbo run test --no-cache --filter=api -- --runInBand --config=jest.e2e-config.ts --coverage --coverageDirectory=../../coverage-e2e/api --coverageReporters=json && pnpm run e2e:teardown",
"e2e:teardown": "cd ../../ && docker compose -f docker-compose-test.yml down"
"e2e": "pnpm run e2e:prepare && cross-env NODE_ENV='e2e' DATABASE_URL='postgresql://prisma:prisma@localhost:5432/tests' jest --runInBand --config=jest.e2e-config.ts --coverage --coverageDirectory=../../coverage-e2e/api --coverageReporters=json && pnpm run e2e:teardown",
"e2e:teardown": "cd ../../ && docker compose -f docker-compose-test.yml down",
"unit": "pnpm db:generate-types && jest --config=jest.config.ts"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
Expand Down Expand Up @@ -67,6 +67,7 @@
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"ajv": "^7",
"dotenv-cli": "^7.4.2",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
Expand All @@ -80,7 +81,6 @@
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
Expand Down
14 changes: 13 additions & 1 deletion apps/api/src/common/authority-checker.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,19 @@ export class AuthorityCheckerService {
const projectAccessLevel = project.accessLevel
switch (projectAccessLevel) {
case ProjectAccessLevel.GLOBAL:
//everyone can access this
// We will only allow reads for the project. If the authority is READ_PROJECT, we will allow access
// For any other authority, the user needs to have the required collective authority over the workspace
// or WORKSPACE_ADMIN authority
if (authority !== Authority.READ_PROJECT) {
if (
!permittedAuthoritiesForWorkspace.has(authority) &&
!permittedAuthoritiesForWorkspace.has(Authority.WORKSPACE_ADMIN)
) {
throw new UnauthorizedException(
`User with id ${userId} does not have the authority in the project with id ${entity?.id}`
)
}
}
break
case ProjectAccessLevel.INTERNAL:
// Any workspace member with the required collective authority over the workspace or
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export class CreateEnvironment {

@IsString()
@IsOptional()
description: string
description?: string

@IsBoolean()
@IsOptional()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "forkedFromId" TEXT,
ADD COLUMN "isForked" BOOLEAN NOT NULL DEFAULT false;

-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_forkedFromId_fkey" FOREIGN KEY ("forkedFromId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
5 changes: 5 additions & 0 deletions apps/api/src/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ model Project {
isDisabled Boolean @default(false) // This is set to true when the user stops his subscription and still has premium features in use
accessLevel ProjectAccessLevel @default(PRIVATE)
pendingCreation Boolean @default(false)
isForked Boolean @default(false)
lastUpdatedBy User? @relation(fields: [lastUpdatedById], references: [id], onUpdate: Cascade, onDelete: SetNull)
lastUpdatedById String?
Expand All @@ -311,6 +312,10 @@ model Project {
environments Environment[]
workspaceRoles ProjectWorkspaceRoleAssociation[]
integrations Integration[]
forks Project[] @relation("Fork")
forkedFromId String?
forkedFrom Project? @relation("Fork", fields: [forkedFromId], references: [id], onDelete: SetNull, onUpdate: Cascade)
}

model ProjectWorkspaceRoleAssociation {
Expand Down
43 changes: 42 additions & 1 deletion apps/api/src/project/controller/project.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { CreateProject } from '../dto/create.project/create.project'
import { UpdateProject } from '../dto/update.project/update.project'
import { RequiredApiKeyAuthorities } from '../../decorators/required-api-key-authorities.decorator'
import { AlphanumericReasonValidationPipe } from '../../common/alphanumeric-reason-pipe'
import { ForkProject } from '../dto/fork.project/fork.project'

@Controller('project')
export class ProjectController {
Expand Down Expand Up @@ -61,12 +62,52 @@ export class ProjectController {
return await this.service.getProjectById(user, projectId)
}

@Post(':projectId/fork')
@RequiredApiKeyAuthorities(Authority.READ_PROJECT, Authority.CREATE_PROJECT)
async forkProject(
@CurrentUser() user: User,
@Param('projectId') projectId: Project['id'],
@Body() forkMetadata: ForkProject
) {
return await this.service.forkProject(user, projectId, forkMetadata)
}

@Put(':projectId/sync-fork')
@RequiredApiKeyAuthorities(Authority.READ_PROJECT, Authority.UPDATE_PROJECT)
async syncFork(
@CurrentUser() user: User,
@Param('projectId') projectId: Project['id'],
@Param('hardSync') hardSync: boolean = false
) {
return await this.service.syncFork(user, projectId, hardSync)
}

@Put(':projectId/unlink-fork')
@RequiredApiKeyAuthorities(Authority.UPDATE_PROJECT)
async unlinkFork(
@CurrentUser() user: User,
@Param('projectId') projectId: Project['id']
) {
return await this.service.unlinkParentOfFork(user, projectId)
}

@Get(':projectId/forks')
@RequiredApiKeyAuthorities(Authority.READ_PROJECT)
async getForks(
@CurrentUser() user: User,
@Param('projectId') projectId: Project['id'],
@Query('page') page: number = 0,
@Query('limit') limit: number = 10
) {
return await this.service.getAllProjectForks(user, projectId, page, limit)
}

@Get('/all/:workspaceId')
@RequiredApiKeyAuthorities(Authority.READ_PROJECT)
async getAllProjects(
@CurrentUser() user: User,
@Param('workspaceId') workspaceId: Workspace['id'],
@Query('page') page: number = 1,
@Query('page') page: number = 0,
@Query('limit') limit: number = 10,
@Query('sort') sort: string = 'name',
@Query('order') order: string = 'asc',
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/project/dto/fork.project/fork.project.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ForkProject } from './fork.project'

describe('ForkProject', () => {
it('should be defined', () => {
expect(new ForkProject()).toBeDefined()
})
})
16 changes: 16 additions & 0 deletions apps/api/src/project/dto/fork.project/fork.project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Workspace } from '@prisma/client'
import { IsOptional, IsString } from 'class-validator'

export class ForkProject {
@IsString()
@IsOptional()
workspaceId?: Workspace['id']

@IsString()
@IsOptional()
name?: string

@IsString()
@IsOptional()
storePrivateKey?: boolean
}
Loading

0 comments on commit 3bab653

Please sign in to comment.