diff --git a/prisma/migrations/20260830020000_add_quote_notification_type/migration.sql b/prisma/migrations/20260830020000_add_quote_notification_type/migration.sql new file mode 100644 index 00000000..0db7f68d --- /dev/null +++ b/prisma/migrations/20260830020000_add_quote_notification_type/migration.sql @@ -0,0 +1,11 @@ +-- Quoting someone's post now notifies them. +-- +-- Postgres refuses ALTER TYPE ... ADD VALUE inside a transaction block before +-- version 12, and Prisma runs every migration in one. From 12 onwards it is +-- allowed, with the single condition that the new value is not *used* in the +-- same transaction - this migration only adds it, so nothing here writes a +-- QUOTE row. This is the first enum change in the repository; the reasoning +-- lives here so the next one does not have to rediscover it. + +-- AlterEnum +ALTER TYPE "public"."NotificationType" ADD VALUE 'QUOTE'; diff --git a/prisma/models/notification.prisma b/prisma/models/notification.prisma index e3275cdb..7992ad37 100644 --- a/prisma/models/notification.prisma +++ b/prisma/models/notification.prisma @@ -5,6 +5,7 @@ enum NotificationType { LIKE COMMENT_LIKE COMMENT_REPLY + QUOTE } model Notification { diff --git a/src/core/domain/enums/notification-type.enum.ts b/src/core/domain/enums/notification-type.enum.ts index 8a39dc00..904d187f 100644 --- a/src/core/domain/enums/notification-type.enum.ts +++ b/src/core/domain/enums/notification-type.enum.ts @@ -30,4 +30,9 @@ export enum NotificationType { * A reply to one of the user's comments */ COMMENT_REPLY = "COMMENT_REPLY", + + /** + * Notification when a user quotes another user's post + */ + QUOTE = "QUOTE", } diff --git a/src/core/use-cases/notification/notify-quoted-author/index.ts b/src/core/use-cases/notification/notify-quoted-author/index.ts new file mode 100644 index 00000000..b8176bc1 --- /dev/null +++ b/src/core/use-cases/notification/notify-quoted-author/index.ts @@ -0,0 +1,2 @@ +export { NotifyQuotedAuthorUseCase } from "./notify-quoted-author.usecase"; +export type { NotifyQuotedAuthorInput } from "./notify-quoted-author.input"; diff --git a/src/core/use-cases/notification/notify-quoted-author/notify-quoted-author.input.ts b/src/core/use-cases/notification/notify-quoted-author/notify-quoted-author.input.ts new file mode 100644 index 00000000..288fd7a3 --- /dev/null +++ b/src/core/use-cases/notification/notify-quoted-author/notify-quoted-author.input.ts @@ -0,0 +1,13 @@ +/** + * Input for the NotifyQuotedAuthorUseCase. + */ +export interface NotifyQuotedAuthorInput { + /** The post that was just published as a quote, and where the notification leads. */ + quotePostId: string; + + /** The post it quotes, whose author is notified. */ + quotedPostId: string; + + /** The account that published the quote, and the issuer of the notification. */ + issuerId: string; +} diff --git a/src/core/use-cases/notification/notify-quoted-author/notify-quoted-author.usecase.ts b/src/core/use-cases/notification/notify-quoted-author/notify-quoted-author.usecase.ts new file mode 100644 index 00000000..fbecc357 --- /dev/null +++ b/src/core/use-cases/notification/notify-quoted-author/notify-quoted-author.usecase.ts @@ -0,0 +1,73 @@ +import { Notification } from "@core/domain/entities/notification.entity"; +import { NotificationType } from "@core/domain/enums/notification-type.enum"; +import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; +import type { RealtimePort } from "@core/ports/services/realtime.port"; +import type { NotifyQuotedAuthorInput } from "./notify-quoted-author.input"; + +/** + * Use case for telling an author that one of their posts was quoted. + * + * Kept separate from post creation for the same reason as + * {@link NotifyNewPostUseCase}: a single caller-agnostic entry point, so + * moving it onto a queue later is a change of caller rather than a rewrite. + */ +export class NotifyQuotedAuthorUseCase { + /** + * Creates a new instance of NotifyQuotedAuthorUseCase. + * + * @param postRepository - Repository used to resolve the quoted post's author + * @param notificationRepository - Repository for persisting the notification + * @param realtimeService - Service for pushing the notification live + */ + constructor( + private readonly postRepository: IPostRepository, + private readonly notificationRepository: INotificationRepository, + private readonly realtimeService: RealtimePort, + ) {} + + /** + * Notifies the author of the quoted post. + * + * @param input - The new quote, the post it quotes, and who published it + * @returns Promise The number of people notified: 1, or 0 + * + * @remarks + * The notification points at the **quote**, not at the post being quoted: + * the recipient already knows their own post, what they want to open is + * what somebody said about it, and the quote carries the original as its + * card anyway. That also buys the cleanup for free - `Notification.post` + * cascades, so deleting the quote takes its notification with it. + * + * Returns 0 without writing anything when the quoted post is already gone + * (a narrow race between the commit and this call) or when an account + * quotes itself, which is not news to anyone. + */ + async execute(input: NotifyQuotedAuthorInput): Promise { + const quotedPost = await this.postRepository.findById( + input.quotedPostId, + ); + if (!quotedPost) return 0; + + const recipientId = quotedPost.author.id; + if (recipientId === input.issuerId) return 0; + + await this.notificationRepository.create( + Notification.create( + recipientId, + input.issuerId, + NotificationType.QUOTE, + { postId: input.quotePostId }, + ), + ); + + this.realtimeService.emitToUser(recipientId, "new-notification", { + type: NotificationType.QUOTE, + issuerId: input.issuerId, + postId: input.quotePostId, + referenceId: input.quotePostId, + }); + + return 1; + } +} diff --git a/src/core/use-cases/post/create-post/create-post.usecase.ts b/src/core/use-cases/post/create-post/create-post.usecase.ts index 57e43e6b..11a562bf 100644 --- a/src/core/use-cases/post/create-post/create-post.usecase.ts +++ b/src/core/use-cases/post/create-post/create-post.usecase.ts @@ -5,6 +5,7 @@ import { Post } from "@core/domain/entities/post.entity"; import type { IUserRepository } from "@core/ports/repositories/user.repository"; import type { LoggerPort } from "@core/ports/services/logger.port"; import type { NotifyNewPostUseCase } from "@core/use-cases/notification/notify-new-post"; +import type { NotifyQuotedAuthorUseCase } from "@core/use-cases/notification/notify-quoted-author"; import { PostType } from "@core/domain/enums"; import { ForbiddenError } from "@core/errors/common/forbidden.error"; import { NotFoundError } from "@core/errors/common/not-found.error"; @@ -23,6 +24,7 @@ export class CreatePostUseCase { * @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 notifyQuotedAuthorUseCase - Use case that tells an author their post was quoted * @param logger - Service for logging operations */ constructor( @@ -30,6 +32,7 @@ export class CreatePostUseCase { private readonly cacheService: CachePort, private readonly userRepository: IUserRepository, private readonly notifyNewPostUseCase: NotifyNewPostUseCase, + private readonly notifyQuotedAuthorUseCase: NotifyQuotedAuthorUseCase, private readonly logger: LoggerPort, ) {} @@ -58,9 +61,10 @@ export class CreatePostUseCase { * 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, - * so a fan-out failure is logged rather than allowed to fail the request. + * Followers and, for a quote, the quoted author are notified after the + * post is committed, deliberately outside the caller's critical path: the + * post is the thing worth keeping, so a notification failure is logged + * rather than allowed to fail the request or roll the write back. */ async execute(input: CreatePostInput): Promise { if ([PostType.SYSTEM_UPDATE, PostType.TECH_NEWS].includes(input.type)) { @@ -120,6 +124,21 @@ export class CreatePostUseCase { ); }); + if (input.quotedPostId) { + void this.notifyQuotedAuthorUseCase + .execute({ + quotePostId: rawPost.id, + quotedPostId: input.quotedPostId, + issuerId: input.authorId, + }) + .catch((err: unknown) => { + this.logger.error( + { err, postId: rawPost.id }, + "Failed to notify the quoted author", + ); + }); + } + return rawPost; } } diff --git a/src/http/plugins/di/use-cases.di.ts b/src/http/plugins/di/use-cases.di.ts index 56cac737..aff5b0c4 100644 --- a/src/http/plugins/di/use-cases.di.ts +++ b/src/http/plugins/di/use-cases.di.ts @@ -35,6 +35,7 @@ import { GetUnreadNotificationCountUseCase } from "@core/use-cases/notification/ import { PurgeExpiredNotificationsUseCase } from "@core/use-cases/notification/purge-expired"; import { CreatePostUseCase } from "@core/use-cases/post/create-post"; import { NotifyNewPostUseCase } from "@core/use-cases/notification/notify-new-post"; +import { NotifyQuotedAuthorUseCase } from "@core/use-cases/notification/notify-quoted-author"; import { UploadPostMediaUseCase } from "@core/use-cases/post/upload-post-media"; import { GetPostsUseCase } from "@core/use-cases/post/get-posts"; import { DeletePostUseCase } from "@core/use-cases/post/delete-post"; @@ -325,6 +326,7 @@ export const useCasesModule = { cacheService, userRepository, notifyNewPostUseCase, + notifyQuotedAuthorUseCase, logger, ) => new CreatePostUseCase( @@ -332,6 +334,7 @@ export const useCasesModule = { cacheService, userRepository, notifyNewPostUseCase, + notifyQuotedAuthorUseCase, logger, ), ).singleton(), @@ -341,6 +344,11 @@ export const useCasesModule = { */ notifyNewPostUseCase: asClass(NotifyNewPostUseCase).singleton(), + /** + * Use case for telling an author that one of their posts was quoted + */ + notifyQuotedAuthorUseCase: asClass(NotifyQuotedAuthorUseCase).singleton(), + /** * Use case for uploading post media files */ diff --git a/tests/e2e/notification/quote.test.ts b/tests/e2e/notification/quote.test.ts new file mode 100644 index 00000000..a38119c3 --- /dev/null +++ b/tests/e2e/notification/quote.test.ts @@ -0,0 +1,164 @@ +import { authRequest, parseBody, request } from "../setup"; +import { beforeAll, describe, expect, it } from "vitest"; + +interface NotificationItem { + id: string; + type: string; + issuerId: string; + // Both are Optional in the response schema, so the key is absent rather + // than null on a notification that points at nothing. + postId?: string; + referenceId?: string; + isRead: boolean; +} + +/** + * E2E tests for QUOTE notifications. + * + * Being quoted is a louder signal than a like: somebody has said something + * about your post to their own followers. The notification leads to the + * quote rather than to the post being quoted, because the recipient already + * knows their own post. + */ +describe("QUOTE notifications", () => { + const ts = Date.now(); + const author = { + email: `nq-author-${ts}@test.com`, + password: "password123", + username: `nqa${ts}`, + }; + const quoter = { + email: `nq-quoter-${ts}@test.com`, + password: "password123", + username: `nqq${ts}`, + }; + + let authorToken = ""; + let quoterToken = ""; + let quoterId = ""; + let originalPostId = ""; + + async function login(user: { + email: string; + password: string; + }): Promise { + 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 notificationsOf(token: string): Promise { + const response = await authRequest(token, { + method: "GET", + url: "/notifications?page=1&limit=50", + }); + return parseBody<{ data: NotificationItem[] }>(response).data; + } + + async function unreadCountOf(token: string): Promise { + const response = await authRequest(token, { + method: "GET", + url: "/notifications/unread-count", + }); + return parseBody<{ data: { count: number } }>(response).data.count; + } + + async function createPost( + token: string, + payload: Record, + ): Promise { + const response = await authRequest(token, { + method: "POST", + url: "/posts", + payload, + }); + expect(response.statusCode).toBe(201); + return parseBody<{ data: { id: string } }>(response).data.id; + } + + /** + * Registers an author and a quoter, and leaves the author with one post + * for the quoter to point at. + */ + beforeAll(async () => { + await request({ + method: "POST", + url: "/auth/register", + payload: author, + }); + const quoterRes = await request({ + method: "POST", + url: "/auth/register", + payload: quoter, + }); + quoterId = parseBody<{ data: { id: string } }>(quoterRes).data.id; + + authorToken = await login(author); + quoterToken = await login(quoter); + + originalPostId = await createPost(authorToken, { + content: "A post worth quoting", + }); + }); + + it("should notify the author, pointing at the quote", async () => { + const before = await unreadCountOf(authorToken); + + const quoteId = await createPost(quoterToken, { + content: "Adding my two cents", + quotedPostId: originalPostId, + }); + + const notifications = await notificationsOf(authorToken); + const quoteNotification = notifications.find( + (item) => item.type === "QUOTE" && item.postId === quoteId, + ); + + expect(quoteNotification).toBeDefined(); + expect(quoteNotification?.issuerId).toBe(quoterId); + expect(quoteNotification?.referenceId).toBe(quoteId); + expect(quoteNotification?.isRead).toBe(false); + expect(await unreadCountOf(authorToken)).toBe(before + 1); + }); + + it("should not notify the quoter about their own quote", async () => { + const notifications = await notificationsOf(quoterToken); + + expect(notifications.some((item) => item.type === "QUOTE")).toBe(false); + }); + + it("should stay silent when an account quotes itself", async () => { + const before = await unreadCountOf(authorToken); + + await createPost(authorToken, { + content: "Quoting myself", + quotedPostId: originalPostId, + }); + + expect(await unreadCountOf(authorToken)).toBe(before); + }); + + it("should take the notification with the quote when it is deleted", async () => { + // Notification.post cascades, so nothing has to clean this up by hand. + const quoteId = await createPost(quoterToken, { + content: "A quote that will not last", + quotedPostId: originalPostId, + }); + + const notified = await notificationsOf(authorToken); + expect(notified.some((item) => item.postId === quoteId)).toBe(true); + + const deleteRes = await authRequest(quoterToken, { + method: "DELETE", + url: `/posts/${quoteId}`, + }); + expect(deleteRes.statusCode).toBe(204); + + const after = await notificationsOf(authorToken); + expect(after.some((item) => item.postId === quoteId)).toBe(false); + }); +}); diff --git a/tests/unit/core/domain/enums/enums.test.ts b/tests/unit/core/domain/enums/enums.test.ts index 3058502b..f1b70110 100644 --- a/tests/unit/core/domain/enums/enums.test.ts +++ b/tests/unit/core/domain/enums/enums.test.ts @@ -78,8 +78,12 @@ describe("Domain Enums", () => { expect(NotificationType.COMMENT_REPLY).toBe("COMMENT_REPLY"); }); - it("should have exactly 6 values", () => { - expect(Object.keys(NotificationType)).toHaveLength(6); + it("should have QUOTE value", () => { + expect(NotificationType.QUOTE).toBe("QUOTE"); + }); + + it("should have exactly 7 values", () => { + expect(Object.keys(NotificationType)).toHaveLength(7); }); }); diff --git a/tests/unit/core/use-cases/notification/notify-quoted-author.usecase.test.ts b/tests/unit/core/use-cases/notification/notify-quoted-author.usecase.test.ts new file mode 100644 index 00000000..e352b8b5 --- /dev/null +++ b/tests/unit/core/use-cases/notification/notify-quoted-author.usecase.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NotifyQuotedAuthorUseCase } from "@core/use-cases/notification/notify-quoted-author"; +import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; +import type { RealtimePort } from "@core/ports/services/realtime.port"; +import { NotificationType } from "@core/domain/enums/notification-type.enum"; +import { buildPost } from "../../../helpers/mock-factories"; + +describe("NotifyQuotedAuthorUseCase", () => { + let useCase: NotifyQuotedAuthorUseCase; + let postRepository: Pick; + let notificationRepository: Pick; + let realtimeService: RealtimePort; + + const input = { + quotePostId: "quote-1", + quotedPostId: "post-0", + issuerId: "user-2", + }; + + beforeEach(() => { + postRepository = { + findById: vi + .fn() + .mockResolvedValue( + buildPost({ id: "post-0", author: { id: "user-1" } }), + ), + }; + notificationRepository = { + create: vi.fn().mockResolvedValue(undefined), + }; + realtimeService = { emitToUser: vi.fn() }; + useCase = new NotifyQuotedAuthorUseCase( + postRepository as IPostRepository, + notificationRepository as INotificationRepository, + realtimeService, + ); + }); + + it("should notify the author of the quoted post", async () => { + const result = await useCase.execute(input); + + expect(result).toBe(1); + expect(notificationRepository.create).toHaveBeenCalledOnce(); + + const notification = vi.mocked(notificationRepository.create).mock + .calls[0][0]; + expect(notification.recipientId).toBe("user-1"); + expect(notification.issuerId).toBe("user-2"); + expect(notification.type).toBe(NotificationType.QUOTE); + }); + + it("should point the notification at the quote, not at the post it quotes", async () => { + // The recipient already knows their own post; what they want to open + // is what somebody said about it. + await useCase.execute(input); + + const notification = vi.mocked(notificationRepository.create).mock + .calls[0][0]; + expect(notification.postId).toBe("quote-1"); + expect(notification.referenceId).toBe("quote-1"); + }); + + it("should push the same target over realtime", async () => { + await useCase.execute(input); + + expect(realtimeService.emitToUser).toHaveBeenCalledWith( + "user-1", + "new-notification", + { + type: NotificationType.QUOTE, + issuerId: "user-2", + postId: "quote-1", + referenceId: "quote-1", + }, + ); + }); + + it("should stay silent when an account quotes itself", async () => { + vi.mocked(postRepository.findById).mockResolvedValue( + buildPost({ id: "post-0", author: { id: "user-2" } }), + ); + + const result = await useCase.execute(input); + + expect(result).toBe(0); + expect(notificationRepository.create).not.toHaveBeenCalled(); + expect(realtimeService.emitToUser).not.toHaveBeenCalled(); + }); + + it("should stay silent when the quoted post is already gone", async () => { + // A narrow race between the post committing and this call. + vi.mocked(postRepository.findById).mockResolvedValue(null); + + const result = await useCase.execute(input); + + expect(result).toBe(0); + expect(notificationRepository.create).not.toHaveBeenCalled(); + expect(realtimeService.emitToUser).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/core/use-cases/post/create-post.usecase.test.ts b/tests/unit/core/use-cases/post/create-post.usecase.test.ts index b7a4862d..66f5acd8 100644 --- a/tests/unit/core/use-cases/post/create-post.usecase.test.ts +++ b/tests/unit/core/use-cases/post/create-post.usecase.test.ts @@ -9,6 +9,7 @@ import type { IUserRepository } from "@core/ports/repositories/user.repository"; import type { CachePort } from "@core/ports/services/cache.port"; import type { LoggerPort } from "@core/ports/services/logger.port"; import type { NotifyNewPostUseCase } from "@core/use-cases/notification/notify-new-post"; +import type { NotifyQuotedAuthorUseCase } from "@core/use-cases/notification/notify-quoted-author"; import { NotFoundError } from "@core/errors/common/not-found.error"; import { ForbiddenError } from "@core/errors/common/forbidden.error"; import { PostType } from "@core/domain/enums/post-type.enum"; @@ -25,6 +26,7 @@ describe("CreatePostUseCase", () => { let userRepository: Pick; let cacheService: Pick; let notifyNewPostUseCase: Pick; + let notifyQuotedAuthorUseCase: Pick; let logger: Pick; beforeEach(() => { @@ -49,12 +51,16 @@ describe("CreatePostUseCase", () => { notifyNewPostUseCase = { execute: vi.fn().mockResolvedValue(0), }; + notifyQuotedAuthorUseCase = { + execute: vi.fn().mockResolvedValue(0), + }; logger = { error: vi.fn() }; useCase = new CreatePostUseCase( transactionService as TransactionPort, cacheService as CachePort, userRepository as IUserRepository, notifyNewPostUseCase as NotifyNewPostUseCase, + notifyQuotedAuthorUseCase as NotifyQuotedAuthorUseCase, logger as LoggerPort, ); }); @@ -263,6 +269,61 @@ describe("CreatePostUseCase", () => { expect(postRepository.incrementQuoteCount).not.toHaveBeenCalled(); }); + it("should tell the quoted author about it", async () => { + const created = buildPost({ id: "quote-1" }); + vi.mocked(postRepository.create).mockResolvedValue(created); + vi.mocked(postRepository.findById).mockResolvedValue( + buildPost({ id: "post-0" }), + ); + + await useCase.execute({ + content: "I agree with this", + type: PostType.COMMUNITY, + authorId: "user-1", + quotedPostId: "post-0", + }); + + await vi.waitFor(() => { + expect(notifyQuotedAuthorUseCase.execute).toHaveBeenCalledWith({ + quotePostId: "quote-1", + quotedPostId: "post-0", + issuerId: "user-1", + }); + }); + }); + + it("should not notify anyone when nothing is quoted", async () => { + await useCase.execute({ + content: "Just a post", + type: PostType.COMMUNITY, + authorId: "user-1", + }); + + expect(notifyQuotedAuthorUseCase.execute).not.toHaveBeenCalled(); + }); + + it("should still return the post when the quote notification fails", async () => { + // The post is the thing worth keeping; a notification failure must + // not surface as a failed request. + const created = buildPost({ id: "quote-1" }); + vi.mocked(postRepository.create).mockResolvedValue(created); + vi.mocked(notifyQuotedAuthorUseCase.execute).mockRejectedValue( + new Error("notifier exploded"), + ); + + const result = await useCase.execute({ + content: "I agree with this", + type: PostType.COMMUNITY, + authorId: "user-1", + quotedPostId: "post-0", + }); + + expect(result).toBe(created); + await vi.waitFor(() => { + expect(logger.error).toHaveBeenCalledOnce(); + }); + }); + it("should allow quoting a quote", async () => { // Only the read side stops at one level; the write side does not // care how deep the chain already goes.