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
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- How many times a post has been quoted, denormalised the way likeCount and
-- commentCount already are: the read path is far hotter than the write path,
-- and counting the quotes relation per row would put a subquery on every feed
-- item.
--
-- Adding a NOT NULL column with a constant default is metadata-only in
-- Postgres 11+, so no table rewrite happens here.

-- AlterTable
ALTER TABLE "public"."posts" ADD COLUMN "quoteCount" INTEGER NOT NULL DEFAULT 0;

-- Defensive backfill. The quote column and this counter ship in the same
-- deploy, so no quote can predate the counter - but a deploy that landed
-- between the two migrations would leave the counter silently at zero with
-- quotes already in the table. Touches only rows that are actually quoted.
UPDATE "public"."posts" p
SET "quoteCount" = sub.c
FROM (
SELECT "quoted_post_id" AS id, COUNT(*) AS c
FROM "public"."posts"
WHERE "quoted_post_id" IS NOT NULL
GROUP BY 1
) sub
WHERE p.id = sub.id;
1 change: 1 addition & 0 deletions prisma/models/post.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ model Post {
notifications Notification[]
commentCount Int @default(0)
likeCount Int @default(0)
quoteCount Int @default(0)

createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
Expand Down
8 changes: 8 additions & 0 deletions src/core/domain/entities/post.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@ export class Post {
return this.props.commentCount!;
}

/**
* Get the number of posts quoting this one
* @returns The quote count, 0 when the post has never been quoted
*/
get quoteCount(): number {
return this.props.quoteCount ?? 0;
}

/**
* Indicates whether the current user has bookmarked the post
* @returns True if the post is bookmarked by the current user, false otherwise
Expand Down
3 changes: 3 additions & 0 deletions src/core/domain/interfaces/post-props.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export interface PostProps {
/** Optional comment count for the post */
commentCount?: number;

/** Optional count of posts quoting this one */
quoteCount?: number;

/** Indicates if the current authenticated user has bookmarked this post */
isBookmarked?: boolean;

Expand Down
12 changes: 12 additions & 0 deletions src/core/ports/repositories/post.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ export interface IPostRepository {
* @param postId - The ID of the post to decrement comment count for
*/
decrementCommentsCount(postId: string): Promise<void>;

/**
* Increments the quote count for a post.
* @param postId - The ID of the post that was quoted.
*/
incrementQuoteCount(postId: string): Promise<void>;

/**
* Decrements the quote count for a post.
* @param postId - The ID of the post whose quote was deleted.
*/
decrementQuoteCount(postId: string): Promise<void>;
/**
* Finds posts by the author's username with pagination and optional type filtering.
* @param username - The username of the author whose posts are being retrieved.
Expand Down
63 changes: 43 additions & 20 deletions src/core/use-cases/post/create-post/create-post.usecase.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type IPostRepository } from "@core/ports/repositories/post.repository";
import type { TransactionPort } from "@core/ports/services/transaction.port";
import type { CreatePostInput } from "./create-post-usecase.input";
import type { CachePort } from "@core/ports/services/cache.port";
import { Post } from "@core/domain/entities/post.entity";
Expand All @@ -19,14 +19,14 @@ export class CreatePostUseCase {
/**
* Creates a new instance of CreatePostUseCase.
*
* @param postRepository - Repository for managing post data
* @param transactionService - Service for running the write atomically
* @param cacheService - Service for cache operations
* @param userRepository - Repository for managing user data
* @param notifyNewPostUseCase - Use case that fans the post out to followers
* @param logger - Service for logging operations
*/
constructor(
private readonly postRepository: IPostRepository,
private readonly transactionService: TransactionPort,
private readonly cacheService: CachePort,
private readonly userRepository: IUserRepository,
private readonly notifyNewPostUseCase: NotifyNewPostUseCase,
Expand All @@ -46,9 +46,17 @@ export class CreatePostUseCase {
* This method creates a new post entity, saves it to the database,
* and clears any cached feed data to ensure consistency.
*
* A quoted post is resolved before the write so a quote can never be
* stored against an id that is already gone. The foreign key would reject
* it too, but a 404 says what happened and a constraint violation does not.
* The post and the quoted post's counter are written in one transaction:
* a post that exists without having been counted would leave the quote
* badge permanently short, and there is no cheap way to notice afterwards.
* The quoted post is resolved inside that transaction too, so a quote can
* never be stored against an id that is already gone - the foreign key
* would reject it as well, but a 404 says what happened and a constraint
* violation does not.
*
* The bot check stays outside the transaction because it only reads, and
* so do the cache purge and the fan-out, which must not hold the write
* open or roll it back.
*
* Followers are notified after the post is committed, deliberately
* outside the caller's critical path: the post is the thing worth keeping,
Expand All @@ -65,23 +73,38 @@ export class CreatePostUseCase {
}
}

if (input.quotedPostId) {
const quoted = await this.postRepository.findById(
input.quotedPostId,
);
if (!quoted) throw new NotFoundError("Quoted post not found.");
}
const rawPost = await this.transactionService.runInTransaction(
async (ctx) => {
if (input.quotedPostId) {
const quoted = await ctx.postRepository.findById(
input.quotedPostId,
);
if (!quoted) {
throw new NotFoundError("Quoted post not found.");
}
}

const post = Post.create(
input.content,
input.type,
input.authorId,
input.mediaUrls || [],
input.categories || [],
input.quotedPostId,
);

const created = await ctx.postRepository.create(post);

if (input.quotedPostId) {
await ctx.postRepository.incrementQuoteCount(
input.quotedPostId,
);
}

const post = Post.create(
input.content,
input.type,
input.authorId,
input.mediaUrls || [],
input.categories || [],
input.quotedPostId,
return created;
},
);

const rawPost = await this.postRepository.create(post);
await this.cacheService.deleteByPattern("posts:feed:*");

void this.notifyNewPostUseCase
Expand Down
17 changes: 16 additions & 1 deletion src/core/use-cases/post/delete-post/delete-post.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { LoggerPort } from "@core/ports/services/logger.port";
import { UnauthorizedActionError, NotFoundError } from "@core/errors";
import type { DeletePostUseCaseInput } from "./delete-post-usecase.input";
import type { CachePort } from "@core/ports/services/cache.port";
import type { TransactionPort } from "@core/ports/services/transaction.port";

/**
* Use case for deleting a post.
Expand All @@ -19,12 +20,14 @@ export class DeletePostUseCase {
* @param storageService - Service for file storage operations
* @param logger - Service for logging operations
* @param cacheService - Service for cache operations
* @param transactionService - Service for running the delete atomically
*/
constructor(
private readonly postRepository: IPostRepository,
private readonly storageService: StoragePort,
private readonly logger: LoggerPort,
private readonly cacheService: CachePort,
private readonly transactionService: TransactionPort,
) {}

/**
Expand All @@ -40,6 +43,11 @@ export class DeletePostUseCase {
* This method validates ownership, deletes associated media files,
* clears cache entries, and removes the post from the database.
* Media deletion errors are logged but don't prevent post deletion.
*
* Deleting a quote also gives the quoted post its count back, in the same
* transaction as the delete so the two cannot come apart. Deleting a
* quoted post needs no such care: its own quotes are cascaded away with
* it, and no surviving row was counting them.
*/
async execute(input: DeletePostUseCaseInput): Promise<void> {
const post = await this.postRepository.findById(input.postId);
Expand Down Expand Up @@ -72,6 +80,13 @@ export class DeletePostUseCase {
}

await this.cacheService.deleteByPattern("posts:feed:*");
await this.postRepository.delete(input.postId);

await this.transactionService.runInTransaction(async (ctx) => {
if (post.quotedPostId) {
await ctx.postRepository.decrementQuoteCount(post.quotedPostId);
}

await ctx.postRepository.delete(input.postId);
});
}
}
13 changes: 10 additions & 3 deletions src/http/plugins/di/use-cases.di.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,14 +321,14 @@ export const useCasesModule = {
*/
createPostUseCase: asFunction(
(
postRepository,
transactionService,
cacheService,
userRepository,
notifyNewPostUseCase,
logger,
) =>
new CreatePostUseCase(
postRepository,
transactionService,
cacheService,
userRepository,
notifyNewPostUseCase,
Expand Down Expand Up @@ -362,12 +362,19 @@ export const useCasesModule = {
* Use case for deleting a post
*/
deletePostUseCase: asFunction(
(postRepository, storageService, logger, cacheService) =>
(
postRepository,
storageService,
logger,
cacheService,
transactionService,
) =>
new DeletePostUseCase(
postRepository,
storageService,
logger,
cacheService,
transactionService,
),
).singleton(),

Expand Down
1 change: 1 addition & 0 deletions src/http/types/schemas/post/get-post.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const PostItemSchema = FBType.Object({
createdAt: FBType.String(),
likeCount: FBType.Number(),
commentCount: FBType.Number(),
quoteCount: FBType.Number(),
isLiked: FBType.Boolean(),
isBookmarked: FBType.Boolean(),
author: PostAuthorSchema,
Expand Down
3 changes: 3 additions & 0 deletions src/infrastructure/persistence/mappers/post-prisma.mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export interface PostResponse {
createdAt: Date;
likeCount: number;
commentCount: number;
quoteCount: number;
author: {
id: string;
username: string;
Expand Down Expand Up @@ -103,6 +104,7 @@ export class PostPrismaMapper {
updatedAt: dbPost.updatedAt,
likeCount: dbPost.likeCount,
commentCount: dbPost.commentCount,
quoteCount: dbPost.quoteCount,
isLiked: dbPost.likes && dbPost.likes.length > 0,
isBookmarked: dbPost.bookmarks && dbPost.bookmarks.length > 0,
categories: (dbPost.category as PostCategory[]) || [],
Expand Down Expand Up @@ -183,6 +185,7 @@ export class PostPrismaMapper {
createdAt: post.createdAt,
likeCount: post.likeCount || 0,
commentCount: post.commentCount || 0,
quoteCount: post.quoteCount || 0,
isLiked: post.isLiked || false,
isBookmarked: post.isBookmarked || false,
author: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,30 @@ export class PrismaPostRepository implements IPostRepository {
});
}

/**
* Increments the quote count for a post by its unique identifier.
* @param postId - The unique identifier of the post that was quoted.
* @returns A promise that resolves when the update is complete.
*/
async incrementQuoteCount(postId: string): Promise<void> {
await this.prisma.post.update({
where: { id: postId },
data: { quoteCount: { increment: 1 } },
});
}

/**
* Decrements the quote count for a post by its unique identifier.
* @param postId - The unique identifier of the post whose quote was deleted.
* @returns A promise that resolves when the update is complete.
*/
async decrementQuoteCount(postId: string): Promise<void> {
await this.prisma.post.update({
where: { id: postId },
data: { quoteCount: { decrement: 1 } },
});
}

/**
* Finds posts by the author's username with pagination and optional type filtering.
* @param username - The username of the author whose posts are being retrieved.
Expand Down
31 changes: 31 additions & 0 deletions tests/e2e/post/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,37 @@ describe("POST /posts - Create Post", () => {
expect(body.data.quotedPost).toBeNull();
});

it("should count the quote on the post it quotes", async () => {
const targetRes = await authRequest(accessToken, {
method: "POST",
url: "/posts",
payload: { content: "Counted quote target" },
});
const targetId = parseBody<{
data: { id: string; quoteCount: number };
}>(targetRes);

expect(targetId.data.quoteCount).toBe(0);

await authRequest(accessToken, {
method: "POST",
url: "/posts",
payload: {
content: "Counting quote",
quotedPostId: targetId.data.id,
},
});

const readBack = await request({
method: "GET",
url: `/posts/${targetId.data.id}`,
});
const body = parseBody<{ data: { quoteCount: number } }>(readBack);

expect(readBack.statusCode).toBe(200);
expect(body.data.quoteCount).toBe(1);
});

it("should return 404 when the quoted post does not exist", async () => {
const response = await authRequest(accessToken, {
method: "POST",
Expand Down
Loading