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
5 changes: 5 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ 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";
import { articleInteractionRoutes } from "@routes/article/article-interaction.routes";

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

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

/**
Expand Down
26 changes: 26 additions & 0 deletions src/core/ports/repositories/article-bookmark.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Repository interface for article bookmark operations.
*/
export interface IArticleBookmarkRepository {
/**
* Saves a bookmark for an article
* @param articleId - The article being bookmarked
* @param userId - The user creating the bookmark
*/
save(articleId: string, userId: string): Promise<void>;

/**
* Removes a bookmark for an article
* @param articleId - The article being unbookmarked
* @param userId - The user removing the bookmark
*/
remove(articleId: string, userId: string): Promise<void>;

/**
* Checks whether an article is bookmarked by a user
* @param articleId - The article to check
* @param userId - The user to check for
* @returns True when the article is bookmarked
*/
isBookmarked(articleId: string, userId: string): Promise<boolean>;
}
38 changes: 38 additions & 0 deletions src/core/ports/repositories/article-like.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Repository interface for managing article like relationships.
*/
export interface IArticleLikeRepository {
/**
* Creates a like relationship between a user and an article
* @param articleId - The unique identifier of the article
* @param userId - The unique identifier of the user
*/
like(articleId: string, userId: string): Promise<void>;

/**
* Checks whether a user has already liked an article
* @param articleId - The unique identifier of the article
* @param userId - The unique identifier of the user
* @returns True when the user has liked the article
*/
isLiked(articleId: string, userId: string): Promise<boolean>;

/**
* Removes a like relationship between a user and an article
* @param articleId - The unique identifier of the article
* @param userId - The unique identifier of the user
*/
unlike(articleId: string, userId: string): Promise<void>;

/**
* Increments the cached like count of an article
* @param articleId - The article to update
*/
incrementLikeCount(articleId: string): Promise<void>;

/**
* Decrements the cached like count of an article
* @param articleId - The article to update
*/
decrementLikeCount(articleId: string): Promise<void>;
}
19 changes: 0 additions & 19 deletions src/core/ports/repositories/comment.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,25 +37,6 @@ export interface ICommentRepository {
*/
findById(id: string, currentUserId?: string): Promise<Comment | null>;

/**
* 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
* @param currentUserId - Optional ID of the current user for like/bookmark status
* @returns Promise that resolves to an array of top-level comments
*/
findTopLevelByPostId(
postId: string,
limit: number,
offset: number,
currentUserId?: string,
): Promise<Comment[]>;

/**
* Retrieves top-level comments for a post or an article
* @param target - What the comments are attached to
Expand Down
26 changes: 24 additions & 2 deletions src/core/ports/repositories/tag.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,18 @@
*/
export interface TrendItem {
tag: string;

/**
* Posts carrying this tag.
*
* Keeps its original post-only meaning so existing clients reading it are
* unaffected; articles are reported separately.
*/
postCount: number;

/** Published articles carrying this tag */
articleCount: number;

category: string | null;
}

Expand All @@ -12,7 +23,13 @@ export interface TrendItem {
*/
export interface TagSearchItem {
name: string;

/** Posts carrying this tag */
postCount: number;

/** Published articles carrying this tag */
articleCount: number;

category: string | null;
}

Expand All @@ -31,13 +48,18 @@ export interface TrendingParams {
*/
export interface ITagRepository {
/**
* Returns the most-used tags within the given time window, ordered by post count.
* Returns the most-used tags within the given time window.
*
* Ordered by posts and published articles combined. Drafts never count:
* an unpublished article must not be able to push its tag into a public
* trend list.
*
* @param params - Limit and window size in days.
*/
findTrending(params: TrendingParams): Promise<TrendItem[]>;

/**
* Searches tags by name prefix/substring, ordered by post count descending.
* Searches tags by name prefix/substring, ordered by combined usage.
* @param query - The search string to match against tag names.
* @param limit - Maximum number of results to return. Defaults to 10.
*/
Expand Down
4 changes: 4 additions & 0 deletions src/core/ports/services/transaction.port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { INotificationRepository } from "@core/ports/repositories/notificat
import type { IBookmarkRepository } from "../repositories/bookmark.repository";
import type { IVerificationTokenRepository } from "@core/ports/repositories/verification-token.repository";
import type { IArticleRepository } from "@core/ports/repositories/article.repository";
import type { IArticleLikeRepository } from "@core/ports/repositories/article-like.repository";

/**
* Provides transactional access to repositories within a single atomic operation.
Expand Down Expand Up @@ -36,6 +37,9 @@ export interface TransactionContext {

/** Repository for article-related data operations within the transaction. */
readonly articleRepository: IArticleRepository;

/** Repository for article like operations within the transaction. */
readonly articleLikeRepository: IArticleLikeRepository;
}

/**
Expand Down
6 changes: 6 additions & 0 deletions src/core/use-cases/article/like-article/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Article like module exports.
*/

export * from "./like-article.usecase";
export * from "./like-article-usecase.input";
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Input for liking an article.
*/
export interface LikeArticleUseCaseInput {
/** The article being liked */
articleId: string;

/** The authenticated user */
userId: string;
}
75 changes: 75 additions & 0 deletions src/core/use-cases/article/like-article/like-article.usecase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { TransactionPort } from "@core/ports/services/transaction.port";
import type { RealtimePort } from "@core/ports/services/realtime.port";
import { Notification } from "@core/domain/entities/notification.entity";
import { NotificationType } from "@core/domain/enums/notification-type.enum";
import { NotFoundError } from "@core/errors";
import type { LikeArticleUseCaseInput } from "./like-article-usecase.input";

/**
* Use case for liking an article.
*
* Liking is idempotent: a second like from the same user is a no-op rather
* than an error, so a retried request cannot inflate the count.
*/
export class LikeArticleUseCase {
/**
* @param transactionService - Service for handling database transactions
* @param realtimeService - Service for real-time notifications
*/
constructor(
private readonly transactionService: TransactionPort,
private readonly realtimeService: RealtimePort,
) {}

/**
* Executes the like.
*
* @param input - The article and the user liking it
* @throws NotFoundError - When the article does not exist or is not visible
*/
async execute(input: LikeArticleUseCaseInput): Promise<void> {
await this.transactionService.runInTransaction(async (ctx) => {
const article = await ctx.articleRepository.findById(
input.articleId,
);

// Unpublished articles are invisible to everyone but their author,
// and answering anything other than 404 would confirm one exists.
if (!article || !article.isPublished()) {
throw new NotFoundError("Article not found.");
}

const alreadyLiked = await ctx.articleLikeRepository.isLiked(
input.articleId,
input.userId,
);

if (alreadyLiked) return;

await ctx.articleLikeRepository.like(input.articleId, input.userId);
await ctx.articleLikeRepository.incrementLikeCount(input.articleId);

if (article.author.id !== input.userId) {
const notification = Notification.create(
article.author.id,
input.userId,
NotificationType.LIKE,
input.articleId,
);

await ctx.notificationRepository.create(notification);

this.realtimeService.emitToUser(
article.author.id,
"new-notification",
{
type: NotificationType.LIKE,
issuerId: input.userId,
articleId: input.articleId,
referenceId: input.articleId,
},
);
}
});
}
}
6 changes: 6 additions & 0 deletions src/core/use-cases/article/remove-article-bookmark/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Article bookmark removal module exports.
*/

export * from "./remove-article-bookmark.usecase";
export * from "./remove-article-bookmark-usecase.input";
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Input for removing an article bookmark.
*/
export interface RemoveArticleBookmarkUseCaseInput {
/** The article being unbookmarked */
articleId: string;

/** The authenticated user */
userId: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { IArticleBookmarkRepository } from "@core/ports/repositories/article-bookmark.repository";
import type { RemoveArticleBookmarkUseCaseInput } from "./remove-article-bookmark-usecase.input";

/**
* Use case for removing an article bookmark.
*
* The article itself is not loaded: a bookmark is the user's own row, so
* removing one they hold must keep working even if the article has since been
* archived. Removing a bookmark that does not exist is a no-op.
*/
export class RemoveArticleBookmarkUseCase {
/**
* @param articleBookmarkRepository - Repository for bookmark rows
*/
constructor(
private readonly articleBookmarkRepository: IArticleBookmarkRepository,
) {}

/**
* Executes the removal.
*
* @param input - The article and the user removing their bookmark
*/
async execute(input: RemoveArticleBookmarkUseCaseInput): Promise<void> {
const bookmarked = await this.articleBookmarkRepository.isBookmarked(
input.articleId,
input.userId,
);

if (!bookmarked) return;

await this.articleBookmarkRepository.remove(
input.articleId,
input.userId,
);
}
}
6 changes: 6 additions & 0 deletions src/core/use-cases/article/save-article-bookmark/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Article bookmark module exports.
*/

export * from "./save-article-bookmark.usecase";
export * from "./save-article-bookmark-usecase.input";
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Input for bookmarking an article.
*/
export interface SaveArticleBookmarkUseCaseInput {
/** The article being bookmarked */
articleId: string;

/** The authenticated user */
userId: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { IArticleRepository } from "@core/ports/repositories/article.repository";
import type { IArticleBookmarkRepository } from "@core/ports/repositories/article-bookmark.repository";
import { NotFoundError } from "@core/errors";
import type { SaveArticleBookmarkUseCaseInput } from "./save-article-bookmark-usecase.input";

/**
* Use case for bookmarking an article.
*
* Idempotent: bookmarking twice leaves one bookmark.
*/
export class SaveArticleBookmarkUseCase {
/**
* @param articleRepository - Repository for reading articles
* @param articleBookmarkRepository - Repository for bookmark rows
*/
constructor(
private readonly articleRepository: IArticleRepository,
private readonly articleBookmarkRepository: IArticleBookmarkRepository,
) {}

/**
* Executes the bookmark.
*
* @param input - The article and the user bookmarking it
* @throws NotFoundError - When the article does not exist or is not visible
*/
async execute(input: SaveArticleBookmarkUseCaseInput): Promise<void> {
const article = await this.articleRepository.findById(input.articleId);

if (!article || !article.isPublished()) {
throw new NotFoundError("Article not found.");
}

const already = await this.articleBookmarkRepository.isBookmarked(
input.articleId,
input.userId,
);

if (already) return;

await this.articleBookmarkRepository.save(
input.articleId,
input.userId,
);
}
}
Loading