Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,5 @@ yarn.lock
# Project specific
config.json
config/webrtc.json
generated_client
swagger.json
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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
Warnings:

- You are about to drop the column `rating` on the `Comment` table. All the data in the column will be lost.
- Added the required column `updatedAt` to the `Project` table without a default value. This is not possible if the table is not empty.

*/
-- DropForeignKey
ALTER TABLE "Comment" DROP CONSTRAINT "Comment_parentId_fkey";

-- DropForeignKey
ALTER TABLE "Comment" DROP CONSTRAINT "Comment_projectId_fkey";

-- AlterTable
ALTER TABLE "Comment" DROP COLUMN "rating";

-- AlterTable
ALTER TABLE "Project" ADD COLUMN "publishedAt" TIMESTAMP(3),
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
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.

This migration adds Project.updatedAt as NOT NULL with no default/backfill. On a non-empty table, the ALTER TABLE will fail (as noted in the warning). Provide a default (e.g. DEFAULT now()) and/or backfill existing rows before adding the NOT NULL constraint.

Suggested change
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

Copilot uses AI. Check for mistakes.

-- CreateTable
CREATE TABLE "Like" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"projectId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "Like_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "Like_projectId_idx" ON "Like"("projectId");

-- CreateIndex
CREATE UNIQUE INDEX "Like_userId_projectId_key" ON "Like"("userId", "projectId");

-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Comment"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Like" ADD CONSTRAINT "Like_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Like" ADD CONSTRAINT "Like_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Comment" ADD COLUMN "deleted" BOOLEAN NOT NULL DEFAULT false;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "viewCount" INTEGER NOT NULL DEFAULT 0;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "publishedLongDesc" TEXT,
ADD COLUMN "publishedName" TEXT,
ADD COLUMN "publishedShortDesc" TEXT,
ADD COLUMN "publishedTags" TEXT[] DEFAULT ARRAY[]::TEXT[];
31 changes: 28 additions & 3 deletions prisma/models/project.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,38 @@ model Project {
name String
shortDesc String
longDesc String?
tags String[] @default([])
publishedName String?
publishedShortDesc String?
publishedLongDesc String?
publishedTags String[] @default([])
status ProjectStatus? @default(IN_PROGRESS)
iconUrl String?
monetization MonetizationType? @default(NONE)
price Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
publishedAt DateTime?
userId Int

contentKey String? @map("content_key")
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[]
gameSessions GameSession[]
workSession WorkSession?
userLikes Like[]

// Aggregate statistics
viewCount Int @default(0)
uniquePlayers Int @default(0)
activePlayers Int @default(0)
likes Int @default(0)
Expand All @@ -43,15 +56,27 @@ model Comment {
id Int @id @default(autoincrement())
author User @relation(fields: [authorId], references: [id])
authorId Int
project Project @relation(fields: [projectId], references: [id])
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId Int
parentId Int?
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id])
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
replies Comment[] @relation("CommentReplies")
content String
rating Int
deleted Boolean @default(false)
createdAt DateTime @default(now())

@@index([projectId])
@@index([authorId])
}

model Like {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id])
userId Int
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId Int
createdAt DateTime @default(now())

@@unique([userId, projectId])
@@index([projectId])
}
1 change: 1 addition & 0 deletions prisma/models/user.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ model User {
receivedRequests FriendRequest[] @relation("ReceivedFriendRequests")
subscriptions Subscription[]
comments Comment[]
likes Like[]
creator Project[] @relation("ProjectCreator")
collaborators Project[] @relation("ProjectCollaborators")

Expand Down
4 changes: 3 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ScheduleModule } from "@nestjs/schedule";
import { TasksModule } from "src/tasks/tasks.module";
import { WebRTCModule } from "@webrtc/webrtc.module";
import { MultiplayerModule } from "@multiplayer/multiplayer.module";
import { CommentModule } from "@comment/comment.module";
import { AppConfig } from "src/app.config";

@Module({
Expand All @@ -24,7 +25,8 @@ import { AppConfig } from "src/app.config";
WorkSessionModule,
TasksModule,
WebRTCModule,
MultiplayerModule
MultiplayerModule,
CommentModule
],
providers: [
AppConfig
Expand Down
17 changes: 17 additions & 0 deletions src/auth/guards/optional-jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ExecutionContext, Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";

@Injectable()
export class OptionalJwtAuthGuard extends AuthGuard("jwt") {
override canActivate(context: ExecutionContext) {
return super.canActivate(context);
}

override handleRequest<TUser = unknown>(
_err: unknown,
user: TUser
): TUser | null {
// If the user is not authenticated, return null instead of throwing
return user || null;
}
}
174 changes: 174 additions & 0 deletions src/routes/comment/comment.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Post,
Put,
Query,
Req,
UseGuards
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiQuery,
ApiResponse,
ApiTags
} from "@nestjs/swagger";
import { JwtAuthGuard } from "@auth/guards/jwt-auth.guard";
import { Public } from "@auth/decorators/public.decorator";
import { CommentService } from "./comment.service";
import { CreateCommentDto } from "./dto/create-comment.dto";
import {
CommentResponseDto,
PaginatedCommentsResponseDto
} from "./dto/comment-response.dto";
import { UserDto } from "@auth/dto/user.dto";
import { Request } from "express";
import { PrismaService } from "@ourPrisma/prisma.service";

interface RequestWithUser extends Request {
user: UserDto;
}

@ApiTags("comments")
@Controller("projects/:projectId/comments")
@UseGuards(JwtAuthGuard)
@ApiBearerAuth("JWT-auth")
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.

What is the point of this if you already have UseGuards?

export class CommentController {
constructor(
private readonly commentService: CommentService,
private readonly prisma: PrismaService
) {}

@Public()
@Get()
@ApiOperation({ summary: "Get comments for a project" })
@ApiParam({ name: "projectId", type: "number" })
@ApiQuery({ name: "page", type: "number", required: false })
@ApiQuery({ name: "limit", type: "number", required: false })
@ApiQuery({
name: "sort",
enum: ["newest", "oldest"],
required: false
})
@ApiResponse({
status: 200,
description: "Paginated list of comments",
type: PaginatedCommentsResponseDto
})
async getComments(
@Param("projectId", ParseIntPipe) projectId: number,
@Query("page") page?: string,
@Query("limit") limit?: string,
@Query("sort") sort?: "newest" | "oldest"
): Promise<PaginatedCommentsResponseDto> {
return this.commentService.getComments(
projectId,
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
sort || "newest"
);
}

@Post()
@ApiOperation({ summary: "Create a comment on a project" })
@ApiParam({ name: "projectId", type: "number" })
@ApiBody({ type: CreateCommentDto })
@ApiResponse({
status: 201,
description: "Comment created",
type: CommentResponseDto
})
@HttpCode(HttpStatus.CREATED)
async createComment(
@Param("projectId", ParseIntPipe) projectId: number,
@Body() createCommentDto: CreateCommentDto,
@Req() req: RequestWithUser
): Promise<CommentResponseDto> {
return this.commentService.createComment(
projectId,
req.user.id,
createCommentDto.content
);
}

@Post(":commentId/reply")
@ApiOperation({ summary: "Reply to a comment" })
@ApiParam({ name: "projectId", type: "number" })
@ApiParam({ name: "commentId", type: "number" })
@ApiBody({ type: CreateCommentDto })
@ApiResponse({
status: 201,
description: "Reply created",
type: CommentResponseDto
})
@HttpCode(HttpStatus.CREATED)
async createReply(
@Param("projectId", ParseIntPipe) projectId: number,
@Param("commentId", ParseIntPipe) commentId: number,
@Body() createCommentDto: CreateCommentDto,
@Req() req: RequestWithUser
): Promise<CommentResponseDto> {
return this.commentService.createReply(
projectId,
commentId,
req.user.id,
createCommentDto.content
);
}

@Put(":commentId")
@ApiOperation({ summary: "Edit a comment" })
@ApiParam({ name: "projectId", type: "number" })
@ApiParam({ name: "commentId", type: "number" })
@ApiBody({ type: CreateCommentDto })
@ApiResponse({
status: 200,
description: "Comment updated",
type: CommentResponseDto
})
async updateComment(
@Param("commentId", ParseIntPipe) commentId: number,
@Body() createCommentDto: CreateCommentDto,
@Req() req: RequestWithUser
): Promise<CommentResponseDto> {
return this.commentService.updateComment(
commentId,
req.user.id,
createCommentDto.content
);
}

@Delete(":commentId")
@ApiOperation({ summary: "Delete a comment" })
@ApiParam({ name: "projectId", type: "number" })
@ApiParam({ name: "commentId", type: "number" })
@ApiResponse({ status: 204, description: "Comment deleted" })
@HttpCode(HttpStatus.NO_CONTENT)
async deleteComment(
@Param("projectId", ParseIntPipe) projectId: number,
@Param("commentId", ParseIntPipe) commentId: number,
@Req() req: RequestWithUser
): Promise<void> {
// Check if user is the project creator for extended delete permissions
const project = await this.prisma.project.findUnique({
where: { id: projectId },
select: { userId: true }
});
const isProjectCreator = project?.userId === req.user.id;

return this.commentService.deleteComment(
commentId,
req.user.id,
isProjectCreator
);
Comment on lines +161 to +172
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.

This controller determines isProjectCreator from the projectId route param, but then deletes by commentId without verifying that the comment belongs to that project. A creator of project A could delete a comment on project B by calling DELETE /projects/A/comments/{commentIdOfB}. Pass projectId through to the service and enforce comment.projectId === projectId before allowing creator-based deletion.

Copilot uses AI. Check for mistakes.
}
}
19 changes: 19 additions & 0 deletions src/routes/comment/comment.error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { NotFoundException, BadRequestException } from "@nestjs/common";

export class CommentNotFoundException extends NotFoundException {
constructor(commentId: number) {
super(`Comment with ID ${commentId} not found`);
}
}

export class CommentProjectNotPublishedException extends BadRequestException {
constructor(projectId: number) {
super(`Project with ID ${projectId} is not published`);
}
}

export class CommentNestedReplyException extends BadRequestException {
constructor() {
super("Cannot reply to a reply. Only top-level comments can receive replies.");
}
}
12 changes: 12 additions & 0 deletions src/routes/comment/comment.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { CommentController } from "./comment.controller";
import { CommentService } from "./comment.service";
import { PrismaModule } from "@ourPrisma/prisma.module";

@Module({
imports: [PrismaModule],
controllers: [CommentController],
providers: [CommentService],
exports: [CommentService]
})
export class CommentModule {}
Loading
Loading