Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- Comments can now hang off either a post or an article.
--
-- Both columns are nullable so Prisma can model the two optional relations,
-- which on its own would allow a comment attached to nothing, or to both.
-- The CHECK below is what actually holds the invariant; Prisma cannot express
-- one, so it is written by hand and covered by an integration test that will
-- fail if a future migrate dev regenerates the table without it.
--
-- DROP NOT NULL and adding a nullable column are metadata-only in Postgres,
-- so no table rewrite happens here. The two indexes are not concurrent because
-- Prisma runs migrations inside a transaction; if comments ever grows large
-- enough for that to matter, they should move to a separate manual step.

-- AlterTable
ALTER TABLE "comments" ADD COLUMN "article_id" TEXT,
ALTER COLUMN "post_id" DROP NOT NULL;
-- CreateIndex
CREATE INDEX "comments_article_id_idx" ON "comments"("article_id");
-- CreateIndex
CREATE INDEX "comments_parentId_idx" ON "comments"("parentId");
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_article_id_fkey" FOREIGN KEY ("article_id") REFERENCES "articles"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- Exactly one target, enforced in the database rather than only in code.
ALTER TABLE "comments"
ADD CONSTRAINT "comments_target_xor"
CHECK (num_nonnulls("post_id", "article_id") = 1);
4 changes: 1 addition & 3 deletions prisma/models/article.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,7 @@ model Article {
tags Tag[]
likes ArticleLike[]
bookmarks ArticleBookmark[]

// NOTE: `comments Comment[]` lands with the polymorphic comment migration.
// Comment has no articleId yet, so the back-relation cannot exist today.
comments Comment[]

likeCount Int @default(0) @map("like_count")

Expand Down
14 changes: 10 additions & 4 deletions prisma/models/post.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,15 @@ model Comment {
content String @db.Text
mediaUrls String[] @default([]) @map("media_urls")

postId String @map("post_id")
authorId String @map("author_id")
// Exactly one of postId / articleId is set, enforced by a CHECK constraint
// added in the migration: Prisma cannot express one.
postId String? @map("post_id")
articleId String? @map("article_id")
authorId String @map("author_id")

post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
post Post? @relation(fields: [postId], references: [id], onDelete: Cascade)
article Article? @relation(fields: [articleId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)

parentId String?
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
Expand All @@ -117,6 +121,8 @@ model Comment {
updatedAt DateTime @updatedAt @map("updated_at")

@@index([postId])
@@index([articleId])
@@index([parentId])
@@index([authorId])
@@map("comments")
}
Expand Down
5 changes: 5 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { bookmarkRoutes } from "@routes/post/bookmark.routes";
import { tagRoutes } from "@routes/tags.routes";
import { translateRoutes } from "@routes/translate.routes";
import { articleRoutes } from "@routes/article/article.routes";
import { articleCommentRoutes } from "@routes/article/article-comment.routes";

/**
* Main Application class responsible for orchestrating the Fastify server lifecycle.
Expand Down Expand Up @@ -162,6 +163,10 @@ export class App {
this.server.register(articleRoutes, {
prefix: "/api/v1",
});

this.server.register(articleCommentRoutes, {
prefix: "/api/v1",
});
}

/**
Expand Down
87 changes: 83 additions & 4 deletions src/core/domain/entities/comment.entity.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
/**
* Comment entity representing a user comment on a post
* Comment entity representing a user comment on a post or an article
* Supports nested comments through optional parent-child relationships
*/
import type { CommentProps } from "@core/domain/interfaces/comment-props.interface";
import type { CommentTarget } from "@core/ports/repositories/comment.repository";

export class Comment {
/**
Expand Down Expand Up @@ -37,12 +38,34 @@ export class Comment {

/**
* Gets the ID of the post this comment belongs to
* @returns The post ID
* @returns The post ID, or null when the comment belongs to an article
*/
public get postId(): string {
public get postId(): string | null {
return this.props.postId;
}

/**
* Gets the ID of the article this comment belongs to
* @returns The article ID, or null when the comment belongs to a post
*/
public get articleId(): string | null {
return this.props.articleId;
}

/**
* Gets what this comment is attached to.
*
* Callers branch on this rather than on which id happens to be null, so the
* two-nullable-columns representation stays inside the entity.
*
* @returns The comment target
*/
public get target(): CommentTarget {
return this.props.postId !== null
? { type: "POST", id: this.props.postId }
: { type: "ARTICLE", id: this.props.articleId as string };
}

/**
* Gets the ID of the user who authored this comment
* @returns The author user ID
Expand Down Expand Up @@ -76,7 +99,11 @@ export class Comment {
}

/**
* Factory method to create a new comment
* Factory method to create a new comment on a post.
*
* Retained as a delegate to createForPost so existing post comment code
* and its tests keep working unchanged.
*
* @param content - The text content of the comment
* @param postId - The ID of the post this comment belongs to
* @param authorId - The ID of the user who authored this comment
Expand All @@ -90,10 +117,62 @@ export class Comment {
authorId: string,
parentId: string | null = null,
mediaUrls: string[] = [],
): Comment {
return Comment.createForPost(
content,
postId,
authorId,
parentId,
mediaUrls,
);
}

/**
* Factory method to create a comment on a post
* @param content - The text content of the comment
* @param postId - The ID of the post this comment belongs to
* @param authorId - The ID of the user who authored this comment
* @param parentId - Optional parent comment ID for nested comments
* @param mediaUrls - Optional array of media URLs attached to the comment
* @returns A new Comment instance targeting a post
*/
public static createForPost(
content: string,
postId: string,
authorId: string,
parentId: string | null = null,
mediaUrls: string[] = [],
): Comment {
return new Comment({
content,
postId,
articleId: null,
authorId,
parentId,
mediaUrls,
});
}

/**
* Factory method to create a comment on an article
* @param content - The text content of the comment
* @param articleId - The ID of the article this comment belongs to
* @param authorId - The ID of the user who authored this comment
* @param parentId - Optional parent comment ID for nested comments
* @param mediaUrls - Optional array of media URLs attached to the comment
* @returns A new Comment instance targeting an article
*/
public static createForArticle(
content: string,
articleId: string,
authorId: string,
parentId: string | null = null,
mediaUrls: string[] = [],
): Comment {
return new Comment({
content,
postId: null,
articleId,
authorId,
parentId,
mediaUrls,
Expand Down
5 changes: 5 additions & 0 deletions src/core/domain/enums/notification-type.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,9 @@ export enum NotificationType {
*
*/
COMMENT_LIKE = "COMMENT_LIKE",

/**
* A reply to one of the user's comments
*/
COMMENT_REPLY = "COMMENT_REPLY",
}
14 changes: 12 additions & 2 deletions src/core/domain/interfaces/comment-props.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,19 @@ export interface CommentProps {
content: string;

/**
* ID of the post this comment belongs to
* ID of the post this comment belongs to.
*
* Null when the comment belongs to an article instead. Exactly one of
* postId and articleId is set; the database enforces it with a CHECK
* constraint.
*/
postId: string;
postId: string | null;

/**
* ID of the article this comment belongs to, or null when it belongs to a
* post.
*/
articleId: string | null;

/**
* ID of the user who authored this comment
Expand Down
20 changes: 20 additions & 0 deletions src/core/errors/article/article-not-published.error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { CustomError } from "../common/custom.error";

/**
* Error thrown when an action requires an article to be published.
*
* Commenting on a draft is the case this exists for: the draft is visible to
* its author, so it is not a 404, but the action is not available yet.
*
* @extends CustomError
*/
export class ArticleNotPublishedError extends CustomError {
/**
* Creates a new ArticleNotPublishedError instance.
*
* @param message - Optional description of what was attempted
*/
constructor(message = "This article has not been published yet.") {
super(message, 409);
}
}
1 change: 1 addition & 0 deletions src/core/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export * from "./post/media-limit-exceeded.error";
export * from "./post/no-media-provided.error";

// Article errors
export * from "./article/article-not-published.error";
export * from "./article/invalid-article-state.error";

// Common HTTP errors
Expand Down
43 changes: 43 additions & 0 deletions src/core/ports/repositories/comment.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@
*/
import type { Comment } from "@core/domain/entities/comment.entity";

/** What a comment can be attached to. */
export type CommentTargetType = "POST" | "ARTICLE";

/**
* A comment target: the kind of thing being commented on, and its id.
*
* Callers pass this instead of a bare id so a post id can never be read as an
* article id by a signature that takes both.
*/
export interface CommentTarget {
/** Whether the comment hangs off a post or an article */
type: CommentTargetType;

/** Identifier of the post or article */
id: string;
}

export interface ICommentRepository {
/**
* Creates a new comment and increments the post's comment count
Expand All @@ -22,6 +39,10 @@ export interface ICommentRepository {

/**
* Retrieves top-level comments for a post (where parentId is null)
*
* @deprecated Prefer findTopLevelByTarget; kept so the post comment path
* is untouched by the polymorphic change, and removed once that path moves
* over.
* @param postId - The ID of the post to get comments for
* @param limit - Maximum number of comments to return
* @param offset - Number of comments to skip for pagination
Expand All @@ -35,6 +56,28 @@ export interface ICommentRepository {
currentUserId?: string,
): Promise<Comment[]>;

/**
* Retrieves top-level comments for a post or an article
* @param target - What the comments are attached to
* @param limit - Maximum number of comments to return
* @param offset - Number of comments to skip for pagination
* @param currentUserId - Optional ID of the current user for like/bookmark status
* @returns Promise that resolves to an array of top-level comments
*/
findTopLevelByTarget(
target: CommentTarget,
limit: number,
offset: number,
currentUserId?: string,
): Promise<Comment[]>;

/**
* Counts the comments attached to a post or an article, replies included
* @param target - What the comments are attached to
* @returns Promise that resolves to the number of comments
*/
countByTarget(target: CommentTarget): Promise<number>;

/**
* Retrieves replies for a specific parent comment
* @param parentId - The ID of the parent comment
Expand Down
6 changes: 6 additions & 0 deletions src/core/ports/services/realtime.port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ export interface RealtimeNotificationPayload {
postId?: string;

commentId?: string;

/** Article the notification points at, when it concerns an article */
articleId?: string;

/** Identifier of the resource the client should deep-link to */
referenceId?: string;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,35 @@
import type { CommentTarget } from "@core/ports/repositories/comment.repository";

/**
* Input type for the CreateComment use case, defining the necessary properties to create a new comment. This includes the content of the comment, the ID of the post it belongs to, the ID of the author creating the comment, an optional parent ID for nested comments, and an optional array of media URLs associated with the comment.
* Input for creating a comment on a post or an article.
*/
export interface CreateCommentUseCaseInput {
/**
* The textual content of the comment being created
*/
content: string;

/**
* The ID of the post to which the comment belongs, used to associate the comment with the correct post in the system
* What the comment is attached to.
*
* A tagged target rather than a bare id, so a post id cannot be silently
* accepted where an article id belongs.
*/
postId: string;
target: CommentTarget;

/**
* The ID of the user who is creating the comment, used to identify the author of the comment in the system
* The ID of the user who is creating the comment
*/
authorId: string;

/**
* Optional ID of the parent comment if this comment is a reply to another comment, allowing for nested comment structures. If not provided, the comment will be treated as a top-level comment on the post.
* Optional ID of the parent comment, for a nested reply. The parent must
* be attached to the same post or article.
*/
parentId?: string;

/**
* Optional array of media URLs associated with the comment, allowing users to attach images, videos, or other media to their comments. This can enhance the expressiveness of the comment and provide additional context or information related to the comment's content.
* Optional array of media URLs associated with the comment
*/
mediaUrls?: string[];
}
Loading
Loading