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
41 changes: 41 additions & 0 deletions src/core/ports/repositories/notification.repository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Notification } from "@core/domain/entities/notification.entity";
import type { NotificationType } from "@core/domain/enums/notification-type.enum";

/**
* Parameters for paginated notification retrieval.
Expand All @@ -9,6 +10,34 @@ export interface FindNotificationsInput {
skip: number;
}

/**
* Identifies the notification a single undone action produced.
*
* Every field participates in the match, and a target left out must be null
* in the row: a like on a post and a like on an article are the same type
* from the same issuer to the same recipient, and only the target tells them
* apart.
*/
export interface DeleteNotificationInput {
/** The user the notification was delivered to. */
recipientId: string;

/** The user whose action produced it. */
issuerId: string;

/** The kind of notification to remove. */
type: NotificationType;

/** The post it points at, when it points at one. */
postId?: string;

/** The article it points at, when it points at one. */
articleId?: string;

/** The comment it points at, when it points at one. */
commentId?: string;
}

/**
* Repository interface for managing Notification entities.
* Following Clean Architecture principles, this interface defines the contract
Expand Down Expand Up @@ -43,6 +72,18 @@ export interface INotificationRepository {
*/
countByUserId(userId: string): Promise<number>;

/**
* Deletes the notification an undone action had produced.
*
* Unliking or unfollowing must take its notification back with it,
* otherwise the recipient keeps a notification for something that no
* longer happened, and toggling the action piles up duplicates.
*
* @param input - The exact notification to remove.
* @returns The number of notifications deleted, zero when none matched.
*/
deleteByTarget(input: DeleteNotificationInput): Promise<number>;

/**
* Marks a single notification as read.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { TransactionPort } from "@core/ports/services/transaction.port";
import { NotFoundError } from "@core/errors";
import type { UnlikeArticleUseCaseInput } from "./unlike-article-usecase.input";
import { NotificationType } from "@core/domain/enums/notification-type.enum";

/**
* Use case for removing a like from an article.
Expand Down Expand Up @@ -42,6 +43,13 @@ export class UnlikeArticleUseCase {
input.userId,
);
await ctx.articleLikeRepository.decrementLikeCount(input.articleId);

await ctx.notificationRepository.deleteByTarget({
recipientId: article.author.id,
issuerId: input.userId,
type: NotificationType.LIKE,
articleId: input.articleId,
});
});
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { TransactionPort } from "@core/ports/services/transaction.port";
import { NotFoundError } from "@core/errors";
import type { UnlikeCommentUseCaseInput } from "./unlike-comment-usecase.input";
import { NotificationType } from "@core/domain/enums/notification-type.enum";

/**
* Use case for unliking a comment. This use case handles the logic for removing a like from a comment, including checking if the comment exists, verifying that the user has previously liked the comment, updating the like count, and ensuring that all operations are executed within a transaction to maintain data integrity.
Expand Down Expand Up @@ -34,6 +35,15 @@ export class UnlikeCommentUseCase {
input.userId,
);
await ctx.commentRepository.decrementLikeCount(input.commentId);

await ctx.notificationRepository.deleteByTarget({
recipientId: comment.authorId,
issuerId: input.userId,
type: NotificationType.COMMENT_LIKE,
commentId: input.commentId,
postId: comment.postId ?? undefined,
articleId: comment.articleId ?? undefined,
});
});
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { BadRequestError, NotFoundError } from "@core/errors";
import type { IFollowRepository } from "@core/ports/repositories/follow.repository";
import type { IProfileRepository } from "@core/ports/repositories/profile.repository";
import type { INotificationRepository } from "@core/ports/repositories/notification.repository";
import { NotificationType } from "@core/domain/enums/notification-type.enum";
import type { UnFollowUserUseCaseInput, UnFollowUserUseCaseOutput } from "./";

/**
Expand All @@ -15,10 +17,12 @@ export class UnfollowUserUseCase {
*
* @param followUserRepository - Repository for managing follow relationships
* @param profileRepository - Repository for managing user profiles
* @param notificationRepository - Repository for managing notifications
*/
constructor(
private readonly followUserRepository: IFollowRepository,
private readonly profileRepository: IProfileRepository,
private readonly notificationRepository: INotificationRepository,
) {}

/**
Expand Down Expand Up @@ -50,6 +54,15 @@ export class UnfollowUserUseCase {
currentUserId,
targetProfile.userId,
);

// The follow notification goes with the follow: leaving it behind
// tells the target someone follows them who no longer does, and
// re-following would stack a second one on top.
await this.notificationRepository.deleteByTarget({
recipientId: targetProfile.userId,
issuerId: currentUserId,
type: NotificationType.FOLLOW,
});
}

const followersCount =
Expand Down
11 changes: 10 additions & 1 deletion src/core/use-cases/post/unlike-post/unlike-post.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { TransactionPort } from "@core/ports/services/transaction.port";
import { NotFoundError } from "@core/errors";
import type { UnlikePostUseCaseInput } from "./unlike-post-usecase.input";
import type { CachePort } from "@core/ports/services/cache.port";
import { NotificationType } from "@core/domain/enums/notification-type.enum";

/**
* Use case for unliking a post
Expand Down Expand Up @@ -30,7 +31,8 @@ export class UnlikePostUseCase {
* @remarks
* This method first validates that the post exists, then checks if the user
* has previously liked the post. If both conditions are met, it removes the
* like relationship and decrements the like count. If the user hasn't liked the post,
* like relationship, decrements the like count and takes back the
* notification the like had produced. If the user hasn't liked the post,
* the operation is silently ignored (no error thrown).
*/
async execute(input: UnlikePostUseCaseInput): Promise<void> {
Expand All @@ -49,6 +51,13 @@ export class UnlikePostUseCase {
if (hasLiked) {
await ctx.postLikeRepository.unlike(input.postId, input.userId);
await ctx.postLikeRepository.decrementLikeCount(input.postId);

await ctx.notificationRepository.deleteByTarget({
recipientId: post.author.id,
issuerId: input.userId,
type: NotificationType.LIKE,
postId: input.postId,
});
}
});
await this.cacheService.deleteByPattern(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type {
INotificationRepository,
FindNotificationsInput,
DeleteNotificationInput,
} from "@core/ports/repositories/notification.repository";
import type { PrismaTransactionalClient } from "@infrastructure/persistence/database/prisma-client.type";
import type { NotificationType as PrismaNotificationType } from "@generated/prisma/client";
import type { Notification } from "@core/domain/entities/notification.entity";
import { NotificationPrismaMapper } from "@infrastructure/persistence/mappers/notification-prisma.mapper";

Expand Down Expand Up @@ -87,6 +89,24 @@ export class PrismaNotificationRepository implements INotificationRepository {
});
}

async deleteByTarget(input: DeleteNotificationInput): Promise<number> {
// The unset targets are matched as explicit NULLs rather than left
// out: Prisma drops an undefined filter, which would let a post like
// delete the article like sitting next to it.
const result = await this.prisma.notification.deleteMany({
where: {
recipientId: input.recipientId,
issuerId: input.issuerId,
type: input.type as unknown as PrismaNotificationType,
postId: input.postId ?? null,
articleId: input.articleId ?? null,
commentId: input.commentId ?? null,
},
});

return result.count;
}

async markAsRead(
notificationId: string,
recipientId: string,
Expand Down
177 changes: 177 additions & 0 deletions tests/e2e/notification/cleanup-on-undo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { authRequest, parseBody, request } from "../setup";
import { beforeAll, describe, expect, it } from "vitest";

/**
* E2E tests for taking a notification back when its action is undone.
*
* Unliking or unfollowing must remove the notification it produced, so the
* recipient is never left with a notification for something that no longer
* happened, and toggling the action cannot pile up duplicates.
*/
describe("Notification cleanup on undo", () => {
const ts = Date.now();
const owner = {
email: `ntf-cu-a-${ts}@test.com`,
password: "password123",
username: `ntfcua${ts}`,
};
const actor = {
email: `ntf-cu-b-${ts}@test.com`,
password: "password123",
username: `ntfcub${ts}`,
};

let ownerToken = "";
let ownerId = "";
let actorToken = "";
let postId = "";
let ownerCommentId = "";

async function register(user: {
email: string;
password: string;
username: string;
}): Promise<string> {
const response = await request({
method: "POST",
url: "/auth/register",
payload: user,
});
return parseBody<{ data: { id: string } }>(response).data.id;
}

async function login(user: {
email: string;
password: string;
}): Promise<string> {
const response = await request({
method: "POST",
url: "/auth/login",
payload: { identifier: user.email, password: user.password },
});
return parseBody<{ data: { accessToken: string } }>(response).data
.accessToken;
}

async function notificationTypes(token: string): Promise<string[]> {
const response = await authRequest(token, {
method: "GET",
url: "/notifications?limit=50",
});
return parseBody<{ data: { type: string }[] }>(response).data.map(
(n) => n.type,
);
}

async function unreadCount(token: string): Promise<number> {
const response = await authRequest(token, {
method: "GET",
url: "/notifications/unread-count",
});
return parseBody<{ data: { count: number } }>(response).data.count;
}

beforeAll(async () => {
ownerId = await register(owner);
await register(actor);

ownerToken = await login(owner);
actorToken = await login(actor);

const createdPost = await authRequest(ownerToken, {
method: "POST",
url: "/posts",
payload: { content: "Undo cleanup target" },
});
postId = parseBody<{ data: { id: string } }>(createdPost).data.id;

const ownerComment = await authRequest(ownerToken, {
method: "POST",
url: `/posts/${postId}/comments`,
payload: { content: "Owner's own comment" },
});
ownerCommentId = parseBody<{ data: { id: string } }>(ownerComment).data
.id;
});

it("should remove the like notification when the post is unliked", async () => {
await authRequest(actorToken, {
method: "POST",
url: `/posts/${postId}/like`,
});
expect(await notificationTypes(ownerToken)).toContain("LIKE");

await authRequest(actorToken, {
method: "DELETE",
url: `/posts/${postId}/unlike`,
});

expect(await notificationTypes(ownerToken)).not.toContain("LIKE");
});

it("should not stack duplicates when a like is toggled", async () => {
for (let i = 0; i < 3; i++) {
await authRequest(actorToken, {
method: "POST",
url: `/posts/${postId}/like`,
});
await authRequest(actorToken, {
method: "DELETE",
url: `/posts/${postId}/unlike`,
});
}

await authRequest(actorToken, {
method: "POST",
url: `/posts/${postId}/like`,
});

const likes = (await notificationTypes(ownerToken)).filter(
(type) => type === "LIKE",
);
expect(likes).toHaveLength(1);

await authRequest(actorToken, {
method: "DELETE",
url: `/posts/${postId}/unlike`,
});
});

it("should remove the comment like notification when the comment is unliked", async () => {
await authRequest(actorToken, {
method: "POST",
url: `/comments/${ownerCommentId}/like`,
});
expect(await notificationTypes(ownerToken)).toContain("COMMENT_LIKE");

await authRequest(actorToken, {
method: "DELETE",
url: `/comments/${ownerCommentId}/unlike`,
});

expect(await notificationTypes(ownerToken)).not.toContain(
"COMMENT_LIKE",
);
});

it("should remove the follow notification when the follow is undone", async () => {
await authRequest(actorToken, {
method: "POST",
url: "/follows",
payload: { targetId: ownerId },
});
expect(await notificationTypes(ownerToken)).toContain("FOLLOW");

await authRequest(actorToken, {
method: "DELETE",
url: "/follows",
payload: { targetId: ownerId },
});

expect(await notificationTypes(ownerToken)).not.toContain("FOLLOW");
});

it("should leave the owner with nothing unread once everything is undone", async () => {
expect(await unreadCount(ownerToken)).toBe(0);
});
});
Loading