From 97acc281e8e9f4cb17409367d668833e4dfa03e0 Mon Sep 17 00:00:00 2001 From: louis rollet Date: Sun, 5 Apr 2026 16:38:30 +0800 Subject: [PATCH] [PROJECT] [UPDATE] Implement forking functionality for projects, including database schema changes and API updates --- .gitignore | 1 + .../migration.sql | 5 + prisma/models/project.prisma | 4 + .../project/dto/project-response.dto.ts | 11 ++ src/routes/project/project.controller.ts | 23 +++ src/routes/project/project.service.spec.ts | 2 + src/routes/project/project.service.ts | 73 +++++++ swagger.json | 179 ++++++++++++++++++ 8 files changed, 298 insertions(+) create mode 100644 prisma/migrations/20260405081129_add_forked_from/migration.sql diff --git a/.gitignore b/.gitignore index 2a4bb0e..e4ecc14 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,4 @@ yarn.lock # Project specific config.json config/webrtc.json +generated_client/* diff --git a/prisma/migrations/20260405081129_add_forked_from/migration.sql b/prisma/migrations/20260405081129_add_forked_from/migration.sql new file mode 100644 index 0000000..504b2fd --- /dev/null +++ b/prisma/migrations/20260405081129_add_forked_from/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "forked_from_id" INTEGER; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_forked_from_id_fkey" FOREIGN KEY ("forked_from_id") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/models/project.prisma b/prisma/models/project.prisma index aaa5e54..c02f46a 100644 --- a/prisma/models/project.prisma +++ b/prisma/models/project.prisma @@ -26,7 +26,11 @@ model Project { contentExtension String? @map("content_extension") contentUploadedAt DateTime? @map("content_uploaded_at") + forkedFromId Int? @map("forked_from_id") + // Relations + forkedFrom Project? @relation("ProjectForks", fields: [forkedFromId], references: [id], onDelete: SetNull) + forks Project[] @relation("ProjectForks") creator User @relation("ProjectCreator", fields: [userId], references: [id]) collaborators User[] @relation("ProjectCollaborators") comments Comment[] diff --git a/src/routes/project/dto/project-response.dto.ts b/src/routes/project/dto/project-response.dto.ts index 2d0848e..309db97 100644 --- a/src/routes/project/dto/project-response.dto.ts +++ b/src/routes/project/dto/project-response.dto.ts @@ -110,6 +110,15 @@ export class ProjectResponseDto { description: "The number of likes received by the project" }) likes!: number; + + @ApiProperty({ + example: 42, + description: "The ID of the project this was forked from, if any", + type: Number, + nullable: true, + required: false + }) + forkedFromId?: number | null; } export class ProjectExResponseDto extends ProjectResponseDto { @@ -126,6 +135,8 @@ export class ProjectExResponseDto extends ProjectResponseDto { creator!: UserBasicInfoDto; } +export class ForkProjectResponseDto extends ProjectExResponseDto {} + export class CdnUrlResponseDto { @ApiProperty({ example: "https://cdn.example.com/files/project-123?signature=abc123", diff --git a/src/routes/project/project.controller.ts b/src/routes/project/project.controller.ts index 2aae8e7..e81b9da 100644 --- a/src/routes/project/project.controller.ts +++ b/src/routes/project/project.controller.ts @@ -48,6 +48,7 @@ import { Project } from "@prisma/client"; import { ProjectResponseDto, ProjectExResponseDto, + ForkProjectResponseDto, SignedUrlResponseDto } from "./dto/project-response.dto"; import { S3DownloadException } from "@s3/s3.error"; @@ -216,6 +217,28 @@ export class ProjectController { return await this.projectService.create(createProjectDto, userId); } + @Post(":id/fork") + @ApiOperation({ summary: "Fork a published project" }) + @ApiParam({ + name: "id", + type: "number", + description: "Numeric ID of the published project to fork" + }) + @ApiResponse({ + status: 201, + description: "Forked project created successfully", + type: ForkProjectResponseDto + }) + @ApiResponse({ status: 400, description: "Project is not published" }) + @ApiResponse({ status: 404, description: "Project not found" }) + @HttpCode(HttpStatus.CREATED) + async fork( + @Param("id", ParseIntPipe) id: number, + @Req() req: RequestWithUser + ): Promise { + return await this.projectService.fork(id, req.user.id); + } + @Put(":id") @ApiOperation({ summary: "Update an existing project" }) @ApiParam({ diff --git a/src/routes/project/project.service.spec.ts b/src/routes/project/project.service.spec.ts index 889a00a..1b60390 100644 --- a/src/routes/project/project.service.spec.ts +++ b/src/routes/project/project.service.spec.ts @@ -42,6 +42,7 @@ const mockProjects: ProjectWithCreatorAndCollaborators[] = [ contentKey: "keyA", contentExtension: ".zip", contentUploadedAt: new Date(), + forkedFromId: null, creator: { id: 42, email: "creator@example.com", @@ -72,6 +73,7 @@ const mockProjects: ProjectWithCreatorAndCollaborators[] = [ contentKey: "keyB", contentExtension: ".zip", contentUploadedAt: new Date(), + forkedFromId: null, creator: { id: 42, email: "creator@example.com", diff --git a/src/routes/project/project.service.ts b/src/routes/project/project.service.ts index 2bc27dd..2ad0098 100644 --- a/src/routes/project/project.service.ts +++ b/src/routes/project/project.service.ts @@ -487,4 +487,77 @@ export class ProjectService { } }); } + + async fork(sourceProjectId: number, userId: number): Promise { + const sourceProject = await this.prisma.project.findUnique({ + where: { id: sourceProjectId } + }); + + if (!sourceProject) { + throw new NotFoundException( + `Project with ID ${sourceProjectId} not found` + ); + } + + if (sourceProject.status !== "COMPLETED") { + throw new BadRequestException( + "Only published projects can be forked" + ); + } + + const user = await this.prisma.user.findUnique({ + where: { id: userId } + }); + + if (!user) { + throw new NotFoundException(`User with ID ${userId} not found`); + } + + const newProject = await this.prisma.project.create({ + data: { + name: `Fork of ${sourceProject.name}`, + shortDesc: sourceProject.shortDesc, + longDesc: sourceProject.longDesc, + forkedFrom: { connect: { id: sourceProjectId } }, + creator: { connect: { id: userId } }, + collaborators: { connect: [{ id: userId }] } + }, + include: { + collaborators: { + select: ProjectService.COLLABORATOR_SELECT + }, + creator: { + select: ProjectService.CREATOR_SELECT + } + } + }) as ProjectEx; + + // Copy the release content as the first save for the forked project + const releaseContent = await this.s3Service.downloadFile({ + key: `release/${sourceProjectId}` + }); + await this.s3Service.uploadFile({ + file: releaseContent, + keyName: `save/${newProject.id}/${Date.now()}` + }); + + // Copy project image if it exists + try { + const imageKey = `projects/${sourceProjectId}/image`; + const imageExists = await this.s3Service.fileExists(imageKey); + if (imageExists) { + const imageFile = await this.s3Service.downloadFile({ key: imageKey }); + const newImageKey = `projects/${newProject.id}/image`; + await this.s3Service.uploadFile({ + file: imageFile, + keyName: newImageKey + }); + await this.s3Service.setObjectPublicRead(newImageKey); + } + } catch { + // Image copy failure is non-critical + } + + return newProject; + } } diff --git a/swagger.json b/swagger.json index 570f175..dbaeb2e 100644 --- a/swagger.json +++ b/swagger.json @@ -335,6 +335,49 @@ ] } }, + "/projects/{id}/fork": { + "post": { + "operationId": "ProjectController_fork", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "description": "Numeric ID of the published project to fork", + "schema": { + "type": "number" + } + } + ], + "responses": { + "201": { + "description": "Forked project created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForkProjectResponseDto" + } + } + } + }, + "400": { + "description": "Project is not published" + }, + "404": { + "description": "Project not found" + } + }, + "security": [ + { + "JWT-auth": [] + } + ], + "summary": "Fork a published project", + "tags": [ + "projects" + ] + } + }, "/projects/{id}/add-collaborator": { "patch": { "description": "Add a collaborator to a project by providing either userId, username, or email. At least one must be provided.", @@ -2035,6 +2078,12 @@ "type": "number", "example": 87, "description": "The number of likes received by the project" + }, + "forkedFromId": { + "type": "number", + "example": 42, + "description": "The ID of the project this was forked from, if any", + "nullable": true } }, "required": [ @@ -2175,6 +2224,12 @@ "example": 87, "description": "The number of likes received by the project" }, + "forkedFromId": { + "type": "number", + "example": 42, + "description": "The ID of the project this was forked from, if any", + "nullable": true + }, "collaborators": { "description": "The users collaborating on this project", "type": "array", @@ -2233,6 +2288,130 @@ "shortDesc" ] }, + "ForkProjectResponseDto": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 1, + "description": "The unique identifier of the project" + }, + "name": { + "type": "string", + "description": "The name of the project", + "example": "MySuperVideoGame" + }, + "shortDesc": { + "type": "string", + "description": "A short description of the project", + "example": "A 2D platformer game with pixel art graphics" + }, + "longDesc": { + "type": "string", + "description": "A detailed description of the project", + "example": "This game features multiple levels, power-ups, and boss fights.", + "nullable": true + }, + "iconUrl": { + "type": "string", + "description": "URL to the project icon", + "example": "https://example.com/icons/MySuperVideoGame.png", + "nullable": true + }, + "status": { + "type": "string", + "description": "The current status of the project", + "enum": [ + "IN_PROGRESS", + "COMPLETED", + "ARCHIVED" + ], + "example": "IN_PROGRESS", + "nullable": true + }, + "monetization": { + "type": "string", + "description": "The monetization strategy for this project", + "enum": [ + "NONE", + "ADS", + "PAID" + ], + "example": "NONE", + "nullable": true + }, + "price": { + "type": "number", + "example": 99.99, + "description": "The price of the project, if applicable", + "nullable": true + }, + "userId": { + "type": "number", + "example": 1, + "description": "The ID of the user who owns this project" + }, + "createdAt": { + "format": "date-time", + "type": "string", + "example": "2023-04-15T12:00:00Z", + "description": "The date and time when the project was created" + }, + "uniquePlayers": { + "type": "number", + "example": 123, + "description": "The number of unique players who have interacted with this project" + }, + "activePlayers": { + "type": "number", + "example": 42, + "description": "The number of currently active players in this project" + }, + "likes": { + "type": "number", + "example": 87, + "description": "The number of likes received by the project" + }, + "forkedFromId": { + "type": "number", + "example": 42, + "description": "The ID of the project this was forked from, if any", + "nullable": true + }, + "collaborators": { + "description": "The users collaborating on this project", + "type": "array", + "items": { + "$ref": "#/components/schemas/UserBasicInfoDto" + } + }, + "creator": { + "description": "The creator of this project", + "allOf": [ + { + "$ref": "#/components/schemas/UserBasicInfoDto" + } + ] + } + }, + "required": [ + "id", + "name", + "shortDesc", + "longDesc", + "iconUrl", + "status", + "monetization", + "price", + "userId", + "createdAt", + "uniquePlayers", + "activePlayers", + "likes", + "collaborators", + "creator" + ] + }, "UpdateProjectDto": { "type": "object", "properties": {