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,10 @@
/**
* Input for reading a single article by slug.
*/
export interface GetArticleUseCaseInput {
/** The article slug from the URL */
slug: string;

/** The viewer, when authenticated */
viewerId?: string;
}
43 changes: 43 additions & 0 deletions src/core/use-cases/article/get-article/get-article.usecase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Article } from "@core/domain/entities/article.entity";
import type { IArticleRepository } from "@core/ports/repositories/article.repository";
import { NotFoundError } from "@core/errors";
import type { GetArticleUseCaseInput } from "./get-article-usecase.input";

/**
* Use case for reading a single article by its slug.
*
* This is the second of the two layers keeping drafts private. The repository
* returns an article of any status so an author can read their own draft back;
* this use case decides who is allowed to see it.
*/
export class GetArticleUseCase {
/**
* Creates a new instance of GetArticleUseCase.
*
* @param articleRepository - Repository for reading articles
*/
constructor(private readonly articleRepository: IArticleRepository) {}

/**
* Executes the lookup.
*
* @param input - The slug and the viewer
* @returns The article, when the viewer may see it
*
* @throws NotFoundError - When no article matches, or the viewer may not
* see it. Deliberately not a 403: a different status code for a draft that
* exists would confirm the slug, which is the leak this prevents.
*/
async execute(input: GetArticleUseCaseInput): Promise<Article> {
const article = await this.articleRepository.findBySlug(
input.slug,
input.viewerId,
);

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

return article;
}
}
6 changes: 6 additions & 0 deletions src/core/use-cases/article/get-article/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Single article read module exports.
*/

export * from "./get-article.usecase";
export * from "./get-article-usecase.input";
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { PostCategory } from "@core/domain/enums/post-category-enum";

/**
* Input for the public article list.
*
* There is no status filter: this list is published articles only, decided by
* the repository rather than by the caller.
*/
export interface GetArticlesUseCaseInput {
/** 1-based page number */
page?: number;

/** Page size */
limit?: number;

/** Restrict to articles carrying this tag */
tag?: string;

/** Restrict to articles written by this username */
authorUsername?: string;

/** Restrict to articles in any of these categories */
categories?: PostCategory[];

/** Restrict to authors the viewer follows; requires authentication */
followedOnly?: boolean;

/** The viewer, used for like and bookmark flags and for the cache key */
currentUserId?: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Article } from "@core/domain/entities/article.entity";

/**
* Output of the public article list.
*/
export interface GetArticlesUseCaseOutput {
/** The page of articles */
articles: Article[];

/** Total number of articles matching the filters */
total: number;
}
270 changes: 270 additions & 0 deletions src/core/use-cases/article/get-articles/get-articles.usecase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
import { Article } from "@core/domain/entities/article.entity";
import type { ArticleStatus } from "@core/domain/enums";
import type { PostCategory } from "@core/domain/enums/post-category-enum";
import type { IArticleRepository } from "@core/ports/repositories/article.repository";
import type { IFollowRepository } from "@core/ports/repositories/follow.repository";
import type { IUserRepository } from "@core/ports/repositories/user.repository";
import type { CachePort } from "@core/ports/services/cache.port";
import { UnauthorizedError } from "@core/errors";
import type { GetArticlesUseCaseInput } from "./get-articles-usecase.input";
import type { GetArticlesUseCaseOutput } from "./get-articles-usecase.output";

/** How long a rendered page of the list stays cached, in seconds. */
const CACHE_TTL_SECONDS = 60;

/** Default page size when the caller does not ask for one. */
const DEFAULT_LIMIT = 10;

/**
* The exact shape written to the cache.
*
* Declared explicitly rather than spreading whatever the entity happened to
* serialize to: a loose shape keeps stale fields alive across deploys, and the
* reader silently accepts them.
*/
interface CachedArticle {
id: string;
slug: string;
title: string;
body: string;
excerpt: string | null;
coverImageKey: string | null;
coverImageAlt: string | null;
status: string;
publishedAt: string | null;
readingTimeMinutes: number;
author: {
id: string;
username?: string;
avatarUrl?: string;
fullName?: string;
};
tags: string[];
categories: string[];
createdAt: string;
updatedAt: string;
likeCount: number;
commentCount: number;
isLiked: boolean;
isBookmarked: boolean;
}

interface CachedPage {
articles: CachedArticle[];
total: number;
}

/**
* Use case for the public, paginated article list.
*
* Only published articles ever reach this path, and the cache is only ever
* touched here: an author reading their own drafts goes through
* GetMyArticlesUseCase, which shares no cache key space with this one.
*/
export class GetArticlesUseCase {
/**
* Creates a new instance of GetArticlesUseCase.
*
* Parameter names are load-bearing: awilix runs in CLASSIC mode and
* resolves each argument by its name, so they must match the container
* registration keys exactly.
*
* @param articleRepository - Repository for reading articles
* @param cacheService - Cache holding rendered pages of the list
* @param userRepository - Used to resolve an author username to an id
* @param followUserRepository - Used to resolve the followed-authors filter
*/
constructor(
private readonly articleRepository: IArticleRepository,
private readonly cacheService: CachePort,
private readonly userRepository: IUserRepository,
private readonly followUserRepository: IFollowRepository,
) {}

/**
* Executes the list query.
*
* @param input - Pagination and filters
* @returns The page of published articles and the total count
*
* @throws UnauthorizedError - When followedOnly is used without a viewer
*/
async execute(
input: GetArticlesUseCaseInput,
): Promise<GetArticlesUseCaseOutput> {
const page = input.page ?? 1;
const limit = input.limit ?? DEFAULT_LIMIT;
const followedOnly = input.followedOnly ?? false;

if (followedOnly && !input.currentUserId) {
throw new UnauthorizedError(
"Authentication is required to use the followedOnly filter.",
);
}

const cacheKey = this.buildCacheKey(input, page, limit, followedOnly);
const cached = await this.cacheService.get(cacheKey);

if (cached) {
const parsed = JSON.parse(cached) as CachedPage;
return {
articles: parsed.articles.map((entry) => this.fromCache(entry)),
total: parsed.total,
};
}

let authorId: string | undefined;
if (input.authorUsername) {
const author = await this.userRepository.findByUsername(
input.authorUsername,
);

// An unknown username is a filter that matches nothing, not an
// error: it must not be distinguishable from an author with no
// published articles.
if (!author) return { articles: [], total: 0 };

authorId = author.id;
}

const followingIds = followedOnly
? await this.followUserRepository.getFollowingIds(
input.currentUserId as string,
)
: undefined;

const result = await this.articleRepository.findAll({
page,
limit,
tag: input.tag,
authorId,
categories: input.categories,
followingIds,
currentUserId: input.currentUserId,
});

await this.cacheService.set(
cacheKey,
JSON.stringify({
articles: result.articles.map((article) =>
this.toCache(article),
),
total: result.total,
} satisfies CachedPage),
CACHE_TTL_SECONDS,
);

return result;
}

/**
* Builds the cache key for one page of the list.
*
* Every filter appears in the key, and absent values become a literal so
* the key space stays flat and a single pattern delete can clear it.
*
* @param input - The request filters
* @param page - Resolved page number
* @param limit - Resolved page size
* @param followedOnly - Resolved followed-authors flag
* @returns The cache key
*/
private buildCacheKey(
input: GetArticlesUseCaseInput,
page: number,
limit: number,
followedOnly: boolean,
): string {
const tag = input.tag ?? "ALL";
const author = input.authorUsername ?? "ALL";
const categories =
input.categories && input.categories.length > 0
? [...input.categories].sort().join(",")
: "ALL";
const viewer = input.currentUserId ?? "guest";

return (
"articles:list:page:" +
page +
":limit:" +
limit +
":tag:" +
tag +
":author:" +
author +
":categories:" +
categories +
":followedOnly:" +
followedOnly +
":user:" +
viewer
);
}

/**
* Projects an article onto the cached shape.
*
* @param article - The article to cache
* @returns The serializable projection
*/
private toCache(article: Article): CachedArticle {
return {
id: article.id,
slug: article.slug,
title: article.title,
body: article.body,
excerpt: article.excerpt,
coverImageKey: article.coverImageKey,
coverImageAlt: article.coverImageAlt,
status: article.status,
publishedAt: article.publishedAt
? article.publishedAt.toISOString()
: null,
readingTimeMinutes: article.readingTimeMinutes,
author: {
id: article.author.id,
username: article.author.username,
avatarUrl: article.author.avatarUrl,
fullName: article.author.fullName,
},
tags: article.tags,
categories: article.categories,
createdAt: article.createdAt.toISOString(),
updatedAt: article.updatedAt.toISOString(),
likeCount: article.likeCount,
commentCount: article.commentCount,
isLiked: article.isLiked,
isBookmarked: article.isBookmarked,
};
}

/**
* Rebuilds an article from the cached shape, field by field.
*
* @param entry - The cached projection
* @returns The reconstructed article
*/
private fromCache(entry: CachedArticle): Article {
return Article.with({
id: entry.id,
slug: entry.slug,
title: entry.title,
body: entry.body,
excerpt: entry.excerpt,
coverImageKey: entry.coverImageKey,
coverImageAlt: entry.coverImageAlt,
status: entry.status as ArticleStatus,
publishedAt: entry.publishedAt ? new Date(entry.publishedAt) : null,
readingTimeMinutes: entry.readingTimeMinutes,
author: entry.author,
tags: entry.tags,
categories: entry.categories as PostCategory[],
createdAt: new Date(entry.createdAt),
updatedAt: new Date(entry.updatedAt),
likeCount: entry.likeCount,
commentCount: entry.commentCount,
isLiked: entry.isLiked,
isBookmarked: entry.isBookmarked,
});
}
}
7 changes: 7 additions & 0 deletions src/core/use-cases/article/get-articles/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Public article list module exports.
*/

export * from "./get-articles.usecase";
export * from "./get-articles-usecase.input";
export * from "./get-articles-usecase.output";
Loading