Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,4 @@ yarn.lock
# Project specific
config.json
config/webrtc.json
generated_client/*
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions prisma/models/project.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a list of projects that forked from this one?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no it's a list of project that forked the current project

creator User @relation("ProjectCreator", fields: [userId], references: [id])
collaborators User[] @relation("ProjectCollaborators")
comments Comment[]
Expand Down
11 changes: 11 additions & 0 deletions src/routes/project/dto/project-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions src/routes/project/project.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<ForkProjectResponseDto> {
return await this.projectService.fork(id, req.user.id);
}

@Put(":id")
@ApiOperation({ summary: "Update an existing project" })
@ApiParam({
Expand Down
2 changes: 2 additions & 0 deletions src/routes/project/project.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const mockProjects: ProjectWithCreatorAndCollaborators[] = [
contentKey: "keyA",
contentExtension: ".zip",
contentUploadedAt: new Date(),
forkedFromId: null,
creator: {
id: 42,
email: "creator@example.com",
Expand Down Expand Up @@ -72,6 +73,7 @@ const mockProjects: ProjectWithCreatorAndCollaborators[] = [
contentKey: "keyB",
contentExtension: ".zip",
contentUploadedAt: new Date(),
forkedFromId: null,
creator: {
id: 42,
email: "creator@example.com",
Expand Down
73 changes: 73 additions & 0 deletions src/routes/project/project.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,4 +487,77 @@ export class ProjectService {
}
});
}

async fork(sourceProjectId: number, userId: number): Promise<ProjectEx> {
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"
);
}
Comment on lines +491 to +506
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fork() introduces a new behavior path (validations + DB create + S3 copy) but there are currently no unit tests covering success and failure scenarios (e.g., non-existent source project, non-published source, missing user, S3 download/upload failure cleanup). Adding tests in project.service.spec.ts for this method would help prevent regressions and ensure the rollback/compensation behavior is correct.

Copilot uses AI. Check for mistakes.

const user = await this.prisma.user.findUnique({
where: { id: userId }
});

if (!user) {
throw new NotFoundException(`User with ID ${userId} not found`);
}
Comment on lines +508 to +514
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whose userId is this ?
If it's the user doing the query, then the JWTGuard already ensures that the user exists, making this query and the check after this one redondant.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, doesn't make sense. Please do the necessary changes


const newProject = await this.prisma.project.create({
data: {
name: `Fork of ${sourceProject.name}`,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would happen if someone forks a project that was already forked ? Would it be named "Fork of Fork of originalProjectName" ?
Wouldn't it be better to let the user name the new project whatever they want, but add an indicator that it is a fork from another project ?
OR, name it originalProjectName and add an indicator that it was forked

shortDesc: sourceProject.shortDesc,
longDesc: sourceProject.longDesc,
forkedFrom: { connect: { id: sourceProjectId } },
creator: { connect: { id: userId } },
collaborators: { connect: [{ id: userId }] }
},
Comment on lines +516 to +524
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fork() creates the new Project row before doing the S3 release copy. If downloadFile/uploadFile throws, the request fails but the forked project remains persisted without an initial save, leaving the system in an inconsistent state. Consider wrapping the post-create S3 steps in a try/catch and compensating (e.g., delete the newly created project and any partially uploaded objects) when the copy fails, or otherwise making the operation idempotent/rollback-safe.

Copilot uses AI. Check for mistakes.
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really like this: In which case would this happen? catching and doing nothing hides nasty bugs, so please avoid doing this

}
Comment on lines +545 to +559
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The image-copy block swallows all errors (catch {}) with no logging. If S3 permissions/keys are misconfigured this will fail silently and be hard to diagnose. Consider at least logging the exception (with sourceProjectId/newProject.id) or narrowing the catch to expected "not found" cases while surfacing unexpected failures.

Copilot uses AI. Check for mistakes.

return newProject;
}
}
179 changes: 179 additions & 0 deletions swagger.json
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this file from this PR, this will make my merge easier on my end

Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
Loading