From d6b0104cbd1ea5235482373a5b90586d8272d1e0 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 27 Aug 2026 00:07:50 +0300 Subject: [PATCH] feat(notification): carry the whole deep-link target on every notification Tapping a notification could not open what it was about. Post likes and comment likes persisted no referenceId at all, so the stored row had no destination even though the realtime event carried one. Comment notifications stored only the comment id, leaving the client to resolve the post or article it lives under. Post and article likes shared the LIKE type with nothing to tell them apart, articles are read by slug while notifications only knew their uuid, and the notification id was never mapped out of Prisma, so a single notification could not be addressed at all. Notifications now store postId / articleId / commentId next to referenceId, which the entity derives from the target: the comment, else the article, else the post. Article slugs are resolved on read rather than denormalised, since a slug follows its title. The columns are real foreign keys with ON DELETE CASCADE, so a notification cannot outlive what it points at. The response exposes the notification id along with the target ids, and the realtime payload carries the article slug so both paths can build the same URL. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PwzkQ5YGFXSB9jWCZKzX4H --- .../migration.sql | 22 +++ prisma/models/article.prisma | 9 +- prisma/models/notification.prisma | 23 ++- prisma/models/post.prisma | 14 +- .../domain/entities/notification.entity.ts | 73 ++++++++- .../notification-props.interface.ts | 15 ++ src/core/ports/services/realtime.port.ts | 3 + .../like-article/like-article.usecase.ts | 3 +- .../create-comment/create-comment.usecase.ts | 25 +-- .../like-comment/like-comment.usecase.ts | 5 + .../post/like-post/like-post.usecase.ts | 2 + .../notification/get-notification.schema.ts | 10 ++ .../mappers/notification-prisma.mapper.ts | 29 ++++ .../prisma-notification.repository.ts | 7 + tests/e2e/notification/deep-links.test.ts | 152 ++++++++++++++++++ .../prisma-notification.repository.test.ts | 93 ++++++++++- .../entities/notification.entity.test.ts | 60 ++++++- .../article-interactions.usecase.test.ts | 11 ++ .../create-article-comment.usecase.test.ts | 41 +++++ .../comment/create-comment.usecase.test.ts | 7 + .../comment/like-comment.usecase.test.ts | 42 +++++ .../use-cases/post/like-post.usecase.test.ts | 15 ++ .../notification-prisma.mapper.test.ts | 93 +++++++++++ 23 files changed, 724 insertions(+), 30 deletions(-) create mode 100644 prisma/migrations/20260826233221_notification_deep_link_targets/migration.sql create mode 100644 tests/e2e/notification/deep-links.test.ts diff --git a/prisma/migrations/20260826233221_notification_deep_link_targets/migration.sql b/prisma/migrations/20260826233221_notification_deep_link_targets/migration.sql new file mode 100644 index 00000000..7662ca73 --- /dev/null +++ b/prisma/migrations/20260826233221_notification_deep_link_targets/migration.sql @@ -0,0 +1,22 @@ +-- AlterTable +ALTER TABLE "public"."notifications" ADD COLUMN "articleId" TEXT, +ADD COLUMN "commentId" TEXT, +ADD COLUMN "postId" TEXT; + +-- CreateIndex +CREATE INDEX "notifications_postId_idx" ON "public"."notifications"("postId" ASC); + +-- CreateIndex +CREATE INDEX "notifications_articleId_idx" ON "public"."notifications"("articleId" ASC); + +-- CreateIndex +CREATE INDEX "notifications_commentId_idx" ON "public"."notifications"("commentId" ASC); + +-- AddForeignKey +ALTER TABLE "public"."notifications" ADD CONSTRAINT "notifications_postId_fkey" FOREIGN KEY ("postId") REFERENCES "public"."posts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."notifications" ADD CONSTRAINT "notifications_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "public"."articles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."notifications" ADD CONSTRAINT "notifications_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "public"."comments"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/models/article.prisma b/prisma/models/article.prisma index bf46dd8b..ff71946c 100644 --- a/prisma/models/article.prisma +++ b/prisma/models/article.prisma @@ -25,10 +25,11 @@ model Article { authorId String @map("author_id") author User @relation(fields: [authorId], references: [id], onDelete: Cascade) - tags Tag[] - likes ArticleLike[] - bookmarks ArticleBookmark[] - comments Comment[] + tags Tag[] + likes ArticleLike[] + bookmarks ArticleBookmark[] + comments Comment[] + notifications Notification[] likeCount Int @default(0) @map("like_count") diff --git a/prisma/models/notification.prisma b/prisma/models/notification.prisma index 0fde8e80..e3275cdb 100644 --- a/prisma/models/notification.prisma +++ b/prisma/models/notification.prisma @@ -13,9 +13,21 @@ model Notification { recipientId String issuerId String - type NotificationType + type NotificationType + + // Kept as the single "what this points at" id so existing clients keep + // working. It mirrors the most specific target below: comment, else + // article, else post. referenceId String? + // The content the client must open when the notification is tapped. + // At most one of postId / articleId is set - together with commentId they + // describe the whole destination: a comment notification carries both the + // comment and the post or article it lives under. + postId String? + articleId String? + commentId String? + isRead Boolean @default(false) createdAt DateTime @default(now()) @@ -23,7 +35,16 @@ model Notification { recipient User @relation("ReceivedNotifications", fields: [recipientId], references: [id], onDelete: Cascade) issuer User @relation("IssuedNotifications", fields: [issuerId], references: [id], onDelete: Cascade) + // Cascades keep notifications from outliving what they link to, so a tapped + // notification can never land on deleted content. + post Post? @relation(fields: [postId], references: [id], onDelete: Cascade) + article Article? @relation(fields: [articleId], references: [id], onDelete: Cascade) + comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade) + @@index([recipientId]) @@index([createdAt]) + @@index([postId]) + @@index([articleId]) + @@index([commentId]) @@map("notifications") } diff --git a/prisma/models/post.prisma b/prisma/models/post.prisma index a0bfb518..580cbd78 100644 --- a/prisma/models/post.prisma +++ b/prisma/models/post.prisma @@ -23,10 +23,11 @@ model Post { authorId String @map("author_id") author User @relation(fields: [authorId], references: [id], onDelete: Cascade) - tags Tag[] - likes PostLike[] - bookmarks PostBookmark[] - comments Comment[] + tags Tag[] + likes PostLike[] + bookmarks PostBookmark[] + comments Comment[] + notifications Notification[] commentCount Int @default(0) likeCount Int @default(0) @@ -112,8 +113,9 @@ model Comment { parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade) replies Comment[] @relation("CommentReplies") - likes CommentLike[] - bookmarks CommentBookmark[] + likes CommentLike[] + bookmarks CommentBookmark[] + notifications Notification[] likeCount Int @default(0) @map("like_count") replyCount Int @default(0) @map("reply_count") diff --git a/src/core/domain/entities/notification.entity.ts b/src/core/domain/entities/notification.entity.ts index 8c9f32fb..7d47a531 100644 --- a/src/core/domain/entities/notification.entity.ts +++ b/src/core/domain/entities/notification.entity.ts @@ -1,6 +1,24 @@ import type { NotificationType } from "../enums"; import type { NotificationProps } from "../interfaces/notification-props.interface"; +/** + * The content a notification points at. + * + * At most one of `postId` / `articleId` is set. `commentId` is set alongside + * one of them whenever the notification concerns a comment, because opening a + * comment always means opening the post or article it lives under first. + */ +export interface NotificationTarget { + /** The post the notification points at */ + postId?: string; + + /** The article the notification points at */ + articleId?: string; + + /** The comment the notification points at */ + commentId?: string; +} + /** * Rich domain model for Notification entity * @@ -20,23 +38,32 @@ export class Notification { * Factory method that ensures all required properties are provided * while setting sensible defaults for optional properties. * + * `referenceId` is derived from the target rather than passed in, so every + * notification that has somewhere to go carries one: the most specific id + * wins - the comment, else the article, else the post. + * * @param recipientId - The ID of the user receiving the notification * @param issuerId - The ID of the user issuing the notification * @param type - The type of the notification - * @param referenceId - Optional reference ID for the notification + * @param target - What the notification points at, empty for a follow * @returns A new Notification entity */ public static create( recipientId: string, issuerId: string, type: NotificationType, - referenceId?: string, + target: NotificationTarget = {}, ): Notification { + const { postId, articleId, commentId } = target; + return new Notification({ recipientId, issuerId, type, - referenceId, + referenceId: commentId ?? articleId ?? postId, + postId, + articleId, + commentId, username: undefined, avatarUrl: undefined, createdAt: undefined, @@ -96,6 +123,46 @@ export class Notification { return this.props.referenceId; } + /** + * Get the ID of the notification itself + * @returns The notification ID, or undefined while it is not persisted yet + */ + get id(): string | undefined { + return this.props.id; + } + + /** + * Get the ID of the post this notification points at + * @returns The post ID or undefined when it concerns something else + */ + get postId(): string | undefined { + return this.props.postId; + } + + /** + * Get the ID of the article this notification points at + * @returns The article ID or undefined when it concerns something else + */ + get articleId(): string | undefined { + return this.props.articleId; + } + + /** + * Get the ID of the comment this notification points at + * @returns The comment ID or undefined when it concerns something else + */ + get commentId(): string | undefined { + return this.props.commentId; + } + + /** + * Get the slug of the linked article, resolved when the notification is read + * @returns The article slug or undefined when no article is linked + */ + get articleSlug(): string | undefined { + return this.props.articleSlug; + } + /** * Get the creation date of the notification * @returns The creation timestamp diff --git a/src/core/domain/interfaces/notification-props.interface.ts b/src/core/domain/interfaces/notification-props.interface.ts index 1740f958..d08353b3 100644 --- a/src/core/domain/interfaces/notification-props.interface.ts +++ b/src/core/domain/interfaces/notification-props.interface.ts @@ -8,6 +8,9 @@ import type { NotificationType } from "../enums"; * within the application such as follows, likes, comments, etc. */ export interface NotificationProps { + /** The unique identifier of the notification itself, absent until persisted */ + id?: string; + /** The unique identifier of the user who will receive this notification */ recipientId: string; @@ -20,6 +23,18 @@ export interface NotificationProps { /** Optional reference ID for the related entity (post ID, comment ID, etc.) */ referenceId?: string; + /** The post the notification points at, when it concerns a post */ + postId?: string; + + /** The article the notification points at, when it concerns an article */ + articleId?: string; + + /** The comment the notification points at, when it concerns a comment */ + commentId?: string; + + /** Slug of the linked article, resolved on read so the client can build its URL */ + articleSlug?: string; + /** Optional username of the issuer for display purposes */ username?: string; diff --git a/src/core/ports/services/realtime.port.ts b/src/core/ports/services/realtime.port.ts index e3ba8329..baa44ec4 100644 --- a/src/core/ports/services/realtime.port.ts +++ b/src/core/ports/services/realtime.port.ts @@ -19,6 +19,9 @@ export interface RealtimeNotificationPayload { /** Article the notification points at, when it concerns an article */ articleId?: string; + /** Slug of that article, so the client can build its URL without a lookup */ + articleSlug?: string; + /** Identifier of the resource the client should deep-link to */ referenceId?: string; } diff --git a/src/core/use-cases/article/like-article/like-article.usecase.ts b/src/core/use-cases/article/like-article/like-article.usecase.ts index 4fa06e33..dc6881a4 100644 --- a/src/core/use-cases/article/like-article/like-article.usecase.ts +++ b/src/core/use-cases/article/like-article/like-article.usecase.ts @@ -54,7 +54,7 @@ export class LikeArticleUseCase { article.author.id, input.userId, NotificationType.LIKE, - input.articleId, + { articleId: input.articleId }, ); await ctx.notificationRepository.create(notification); @@ -66,6 +66,7 @@ export class LikeArticleUseCase { type: NotificationType.LIKE, issuerId: input.userId, articleId: input.articleId, + articleSlug: article.slug, referenceId: input.articleId, }, ); diff --git a/src/core/use-cases/comment/create-comment/create-comment.usecase.ts b/src/core/use-cases/comment/create-comment/create-comment.usecase.ts index db22f661..3298231d 100644 --- a/src/core/use-cases/comment/create-comment/create-comment.usecase.ts +++ b/src/core/use-cases/comment/create-comment/create-comment.usecase.ts @@ -40,19 +40,19 @@ export class CreateCommentUseCase { * @param ctx - The transactional repositories * @param target - What is being commented on * @param commenterId - The user attempting to comment - * @returns The id of the user who owns the target + * @returns The owner of the target, plus its slug when it is an article * @throws NotFoundError - When the target does not exist or is not visible * @throws ArticleNotPublishedError - When the author's own article is not published */ - private async resolveTargetAuthor( + private async resolveTarget( ctx: TransactionContext, target: CommentTarget, commenterId: string, - ): Promise { + ): Promise<{ authorId: string; slug?: string }> { if (target.type === "POST") { const post = await ctx.postRepository.findById(target.id); if (!post) throw new NotFoundError("Post not found."); - return post.author.id; + return { authorId: post.author.id }; } const article = await ctx.articleRepository.findById(target.id); @@ -67,7 +67,7 @@ export class CreateCommentUseCase { ); } - return article.author.id; + return { authorId: article.author.id, slug: article.slug }; } /** @@ -81,11 +81,8 @@ export class CreateCommentUseCase { async execute(input: CreateCommentUseCaseInput): Promise { return await this.transactionService.runInTransaction(async (ctx) => { const { target } = input; - const targetAuthorId = await this.resolveTargetAuthor( - ctx, - target, - input.authorId, - ); + const { authorId: targetAuthorId, slug: targetSlug } = + await this.resolveTarget(ctx, target, input.authorId); let notifyUserId: string | null = null; let notificationType = NotificationType.COMMENT; @@ -157,7 +154,12 @@ export class CreateCommentUseCase { notifyUserId, input.authorId, notificationType, - savedComment.id, + { + commentId: savedComment.id, + postId: target.type === "POST" ? target.id : undefined, + articleId: + target.type === "ARTICLE" ? target.id : undefined, + }, ); await ctx.notificationRepository.create(notification); @@ -171,6 +173,7 @@ export class CreateCommentUseCase { postId: target.type === "POST" ? target.id : undefined, articleId: target.type === "ARTICLE" ? target.id : undefined, + articleSlug: targetSlug, commentId: savedComment.id, referenceId: savedComment.id, }, diff --git a/src/core/use-cases/comment/like-comment/like-comment.usecase.ts b/src/core/use-cases/comment/like-comment/like-comment.usecase.ts index ef913ab9..960939ea 100644 --- a/src/core/use-cases/comment/like-comment/like-comment.usecase.ts +++ b/src/core/use-cases/comment/like-comment/like-comment.usecase.ts @@ -44,6 +44,11 @@ export class LikeCommentUseCase { comment.authorId, input.userId, NotificationType.COMMENT_LIKE, + { + commentId: input.commentId, + postId: comment.postId ?? undefined, + articleId: comment.articleId ?? undefined, + }, ); await ctx.notificationRepository.create(notification); diff --git a/src/core/use-cases/post/like-post/like-post.usecase.ts b/src/core/use-cases/post/like-post/like-post.usecase.ts index 286f90a3..05d62719 100644 --- a/src/core/use-cases/post/like-post/like-post.usecase.ts +++ b/src/core/use-cases/post/like-post/like-post.usecase.ts @@ -51,6 +51,7 @@ export class LikePostUseCase { post.author.id, input.userId, NotificationType.LIKE, + { postId: input.postId }, ); await ctx.notificationRepository.create(notification); @@ -62,6 +63,7 @@ export class LikePostUseCase { type: NotificationType.LIKE, issuerId: input.userId, postId: input.postId, + referenceId: input.postId, }, ); } diff --git a/src/http/types/schemas/notification/get-notification.schema.ts b/src/http/types/schemas/notification/get-notification.schema.ts index 38866c51..552054e6 100644 --- a/src/http/types/schemas/notification/get-notification.schema.ts +++ b/src/http/types/schemas/notification/get-notification.schema.ts @@ -3,12 +3,22 @@ import { Type as FBType, type Static } from "@fastify/type-provider-typebox"; import { MetaOnlyResponseSchema } from "../create-response-schema"; const NotificationItemSchema = FBType.Object({ + id: FBType.String({ format: "uuid" }), recipientId: FBType.String({ format: "uuid" }), issuerId: FBType.String({ format: "uuid" }), username: FBType.Optional(FBType.String()), type: FBType.String(), avatarUrl: FBType.Optional(FBType.String()), + // The most specific target id, kept for clients written against the old + // shape. New clients should read the explicit ids below. referenceId: FBType.Optional(FBType.String()), + // Where tapping the notification leads. A comment notification carries the + // comment plus the post or article it lives under; a follow carries none of + // them and leads to the issuer's profile via `username`. + postId: FBType.Optional(FBType.String({ format: "uuid" })), + articleId: FBType.Optional(FBType.String({ format: "uuid" })), + articleSlug: FBType.Optional(FBType.String()), + commentId: FBType.Optional(FBType.String({ format: "uuid" })), createdAt: FBType.String(), isRead: FBType.Boolean(), }); diff --git a/src/infrastructure/persistence/mappers/notification-prisma.mapper.ts b/src/infrastructure/persistence/mappers/notification-prisma.mapper.ts index 6eb014ce..72f9cb2a 100644 --- a/src/infrastructure/persistence/mappers/notification-prisma.mapper.ts +++ b/src/infrastructure/persistence/mappers/notification-prisma.mapper.ts @@ -9,6 +9,9 @@ export interface PrismaNotificationItem { recipientId: string; issuerId: string; referenceId: string | null; + postId: string | null; + articleId: string | null; + commentId: string | null; isRead: boolean; issuer: { username: string; @@ -16,6 +19,11 @@ export interface PrismaNotificationItem { avatarUrl: string; } | null; }; + // Articles are read by slug, so the slug travels with the notification and + // the client never has to resolve an article id into a URL. + article: { + slug: string; + } | null; } export class NotificationPrismaMapper { @@ -27,10 +35,15 @@ export class NotificationPrismaMapper { */ public static toDomain(item: PrismaNotificationItem): Notification { return Notification.with({ + id: item.id, recipientId: item.recipientId, issuerId: item.issuerId, type: item.type as unknown as CoreNotificationType, referenceId: item.referenceId || undefined, + postId: item.postId || undefined, + articleId: item.articleId || undefined, + commentId: item.commentId || undefined, + articleSlug: item.article?.slug, username: item.issuer.username, avatarUrl: item.issuer.profile?.avatarUrl ?? "", createdAt: item.createdAt, @@ -50,16 +63,22 @@ export class NotificationPrismaMapper { notification: Notification, cdnUrl: string, ): { + id?: string; avatarUrl: string; createdAt: Date; type: CoreNotificationType; recipientId: string; issuerId: string; referenceId?: string; + postId?: string; + articleId?: string; + articleSlug?: string; + commentId?: string; username: string; isRead: boolean; } { return { + id: notification.id, avatarUrl: notification.avatarUrl ? notification.avatarUrl.startsWith("http") ? notification.avatarUrl @@ -72,6 +91,10 @@ export class NotificationPrismaMapper { recipientId: notification.recipientId, issuerId: notification.issuerId, referenceId: notification.referenceId, + postId: notification.postId, + articleId: notification.articleId, + articleSlug: notification.articleSlug, + commentId: notification.commentId, username: notification.username || "", isRead: notification.isRead, }; @@ -87,12 +110,18 @@ export class NotificationPrismaMapper { issuerId: string; type: NotificationType; referenceId?: string | null; + postId?: string | null; + articleId?: string | null; + commentId?: string | null; } { return { recipientId: notification.recipientId, issuerId: notification.issuerId, type: notification.type as unknown as NotificationType, referenceId: notification.referenceId || null, + postId: notification.postId || null, + articleId: notification.articleId || null, + commentId: notification.commentId || null, }; } } diff --git a/src/infrastructure/persistence/repositories/prisma-notification.repository.ts b/src/infrastructure/persistence/repositories/prisma-notification.repository.ts index ca53853a..f137abf4 100644 --- a/src/infrastructure/persistence/repositories/prisma-notification.repository.ts +++ b/src/infrastructure/persistence/repositories/prisma-notification.repository.ts @@ -66,6 +66,13 @@ export class PrismaNotificationRepository implements INotificationRepository { }, }, }, + // Resolved on read rather than denormalised: an article slug + // changes with its title, and a stale one would 404. + article: { + select: { + slug: true, + }, + }, }, }); diff --git a/tests/e2e/notification/deep-links.test.ts b/tests/e2e/notification/deep-links.test.ts new file mode 100644 index 00000000..459b43f8 --- /dev/null +++ b/tests/e2e/notification/deep-links.test.ts @@ -0,0 +1,152 @@ +import { authRequest, parseBody, request } from "../setup"; +import { beforeAll, describe, expect, it } from "vitest"; + +/** + * E2E tests for the deep-link payload of GET /notifications. + * + * Every notification must carry enough to open what it is about without a + * second round trip: the post or article, plus the comment when there is one. + * A follow carries none of those and leads to the issuer's profile instead. + */ +describe("GET /notifications - deep-link targets", () => { + const ts = Date.now(); + const author = { + email: `ntf-dl-a-${ts}@test.com`, + password: "password123", + username: `ntfdla${ts}`, + }; + const actor = { + email: `ntf-dl-b-${ts}@test.com`, + password: "password123", + username: `ntfdlb${ts}`, + }; + + interface NotificationItem { + id: string; + type: string; + referenceId?: string; + postId?: string; + articleId?: string; + articleSlug?: string; + commentId?: string; + username?: string; + } + + let authorToken = ""; + let actorToken = ""; + let postId = ""; + let authorCommentId = ""; + let actorCommentId = ""; + let notifications: NotificationItem[] = []; + + 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; + } + + function findByType(type: string): NotificationItem | undefined { + return notifications.find((n) => n.type === type); + } + + /** + * Drives one of each notification the author can receive on a post: + * a like on the post, a comment on it, and a like on their own comment. + */ + beforeAll(async () => { + await request({ + method: "POST", + url: "/auth/register", + payload: author, + }); + await request({ + method: "POST", + url: "/auth/register", + payload: actor, + }); + + authorToken = await login(author); + actorToken = await login(actor); + + const createdPost = await authRequest(authorToken, { + method: "POST", + url: "/posts", + payload: { content: "Deep-link notification target" }, + }); + postId = parseBody<{ data: { id: string } }>(createdPost).data.id; + + const authorComment = await authRequest(authorToken, { + method: "POST", + url: `/posts/${postId}/comments`, + payload: { content: "Author's own comment" }, + }); + authorCommentId = parseBody<{ data: { id: string } }>(authorComment) + .data.id; + + await authRequest(actorToken, { + method: "POST", + url: `/posts/${postId}/like`, + }); + + const actorComment = await authRequest(actorToken, { + method: "POST", + url: `/posts/${postId}/comments`, + payload: { content: "Nice post" }, + }); + actorCommentId = parseBody<{ data: { id: string } }>(actorComment).data + .id; + + await authRequest(actorToken, { + method: "POST", + url: `/comments/${authorCommentId}/like`, + }); + + const list = await authRequest(authorToken, { + method: "GET", + url: "/notifications?limit=50", + }); + notifications = parseBody<{ data: NotificationItem[] }>(list).data; + }); + + it("should give every notification an addressable id", () => { + expect(notifications.length).toBeGreaterThanOrEqual(3); + for (const notification of notifications) { + expect(notification.id).toEqual(expect.any(String)); + } + }); + + it("should point a post like at the liked post", () => { + const like = findByType("LIKE"); + + expect(like).toBeDefined(); + expect(like?.postId).toBe(postId); + expect(like?.referenceId).toBe(postId); + expect(like?.commentId).toBeUndefined(); + expect(like?.articleId).toBeUndefined(); + }); + + it("should point a comment at both the comment and its post", () => { + const comment = findByType("COMMENT"); + + expect(comment).toBeDefined(); + expect(comment?.commentId).toBe(actorCommentId); + expect(comment?.postId).toBe(postId); + expect(comment?.referenceId).toBe(actorCommentId); + }); + + it("should point a comment like at the liked comment and its post", () => { + const commentLike = findByType("COMMENT_LIKE"); + + expect(commentLike).toBeDefined(); + expect(commentLike?.commentId).toBe(authorCommentId); + expect(commentLike?.postId).toBe(postId); + expect(commentLike?.referenceId).toBe(authorCommentId); + }); +}); diff --git a/tests/integration/persistence/repositories/prisma-notification.repository.test.ts b/tests/integration/persistence/repositories/prisma-notification.repository.test.ts index 6451c7be..39aea866 100644 --- a/tests/integration/persistence/repositories/prisma-notification.repository.test.ts +++ b/tests/integration/persistence/repositories/prisma-notification.repository.test.ts @@ -69,6 +69,97 @@ describe("PrismaNotificationRepository (integration)", () => { }); }); + describe("deep-link targets", () => { + it("should persist the post a notification points at", async () => { + const post = await prisma.post.create({ + data: { content: "Target post", authorId: recipientId }, + }); + + await notifRepo.create( + Notification.create( + recipientId, + issuerId, + NotificationType.LIKE, + { postId: post.id }, + ), + ); + + const [latest] = await notifRepo.findAllByUserId({ + userId: recipientId, + take: 1, + skip: 0, + }); + + expect(latest.postId).toBe(post.id); + expect(latest.referenceId).toBe(post.id); + expect(latest.id).toBeDefined(); + + await prisma.post.delete({ where: { id: post.id } }); + }); + + it("should resolve the article slug so the client can build a URL", async () => { + const article = await prisma.article.create({ + data: { + slug: "notification-target-article", + title: "Notification target", + body: "body", + authorId: recipientId, + }, + }); + const comment = await prisma.comment.create({ + data: { + content: "A comment", + articleId: article.id, + authorId: recipientId, + }, + }); + + await notifRepo.create( + Notification.create( + recipientId, + issuerId, + NotificationType.COMMENT_REPLY, + { commentId: comment.id, articleId: article.id }, + ), + ); + + const [latest] = await notifRepo.findAllByUserId({ + userId: recipientId, + take: 1, + skip: 0, + }); + + expect(latest.commentId).toBe(comment.id); + expect(latest.articleId).toBe(article.id); + expect(latest.articleSlug).toBe("notification-target-article"); + expect(latest.referenceId).toBe(comment.id); + + await prisma.article.delete({ where: { id: article.id } }); + }); + + it("should drop the notification when its target is deleted", async () => { + const post = await prisma.post.create({ + data: { content: "Doomed post", authorId: recipientId }, + }); + + await notifRepo.create( + Notification.create( + recipientId, + issuerId, + NotificationType.LIKE, + { postId: post.id }, + ), + ); + + await prisma.post.delete({ where: { id: post.id } }); + + const orphans = await prisma.notification.count({ + where: { postId: post.id }, + }); + expect(orphans).toBe(0); + }); + }); + describe("getUnreadCount()", () => { it("should return count of unread notifications", async () => { const count = await notifRepo.getUnreadCount(recipientId); @@ -103,7 +194,6 @@ describe("PrismaNotificationRepository (integration)", () => { recipientId, issuerId, NotificationType.LIKE, - "post_100", ), ); @@ -117,7 +207,6 @@ describe("PrismaNotificationRepository (integration)", () => { recipientId, issuerId, NotificationType.LIKE, - "post_101", ), ); diff --git a/tests/unit/core/domain/entities/notification.entity.test.ts b/tests/unit/core/domain/entities/notification.entity.test.ts index c3bcabfc..5d709706 100644 --- a/tests/unit/core/domain/entities/notification.entity.test.ts +++ b/tests/unit/core/domain/entities/notification.entity.test.ts @@ -59,14 +59,70 @@ describe("Notification Entity", () => { expect(n.avatarUrl).toBeUndefined(); }); - it("should set referenceId when provided", () => { + it("should carry the post target and mirror it onto referenceId", () => { const n = Notification.create( "recipient-1", "issuer-1", NotificationType.LIKE, - "post-42", + { postId: "post-42" }, ); + expect(n.postId).toBe("post-42"); expect(n.referenceId).toBe("post-42"); + expect(n.articleId).toBeUndefined(); + expect(n.commentId).toBeUndefined(); + }); + + it("should carry the article target and mirror it onto referenceId", () => { + const n = Notification.create( + "recipient-1", + "issuer-1", + NotificationType.LIKE, + { articleId: "article-7" }, + ); + expect(n.articleId).toBe("article-7"); + expect(n.referenceId).toBe("article-7"); + expect(n.postId).toBeUndefined(); + }); + + it("should keep both the comment and the post it lives under", () => { + const n = Notification.create( + "recipient-1", + "issuer-1", + NotificationType.COMMENT_LIKE, + { commentId: "comment-9", postId: "post-42" }, + ); + expect(n.commentId).toBe("comment-9"); + expect(n.postId).toBe("post-42"); + }); + + it("should prefer the comment id for referenceId over its parent", () => { + const n = Notification.create( + "recipient-1", + "issuer-1", + NotificationType.COMMENT_REPLY, + { commentId: "comment-9", articleId: "article-7" }, + ); + expect(n.referenceId).toBe("comment-9"); + }); + + it("should leave every target id undefined for a follow", () => { + const n = Notification.create( + "recipient-1", + "issuer-1", + NotificationType.FOLLOW, + ); + expect(n.postId).toBeUndefined(); + expect(n.articleId).toBeUndefined(); + expect(n.commentId).toBeUndefined(); + }); + + it("should leave id undefined until the notification is persisted", () => { + const n = Notification.create( + "recipient-1", + "issuer-1", + NotificationType.FOLLOW, + ); + expect(n.id).toBeUndefined(); }); it("should leave referenceId undefined when not provided", () => { diff --git a/tests/unit/core/use-cases/article/article-interactions.usecase.test.ts b/tests/unit/core/use-cases/article/article-interactions.usecase.test.ts index 55ba582f..3e92e773 100644 --- a/tests/unit/core/use-cases/article/article-interactions.usecase.test.ts +++ b/tests/unit/core/use-cases/article/article-interactions.usecase.test.ts @@ -106,6 +106,17 @@ describe("article like and bookmark use cases", () => { ); }); + it("should persist the liked article on the notification", async () => { + await useCase.execute({ articleId: ARTICLE, userId: READER }); + + const [notification] = vi.mocked(notificationRepo.create).mock + .calls[0]; + expect(notification.articleId).toBe(ARTICLE); + expect(notification.referenceId).toBe(ARTICLE); + expect(notification.postId).toBeUndefined(); + expect(notification.commentId).toBeUndefined(); + }); + it("should not notify when the author likes their own article", async () => { await useCase.execute({ articleId: ARTICLE, userId: AUTHOR }); diff --git a/tests/unit/core/use-cases/comment/create-article-comment.usecase.test.ts b/tests/unit/core/use-cases/comment/create-article-comment.usecase.test.ts index df9c52f9..346914be 100644 --- a/tests/unit/core/use-cases/comment/create-article-comment.usecase.test.ts +++ b/tests/unit/core/use-cases/comment/create-article-comment.usecase.test.ts @@ -204,6 +204,47 @@ describe("CreateCommentUseCase (article target)", () => { postId: undefined, }), ); + + const [notification] = vi.mocked(txNotificationRepo.create).mock + .calls[0]; + expect(notification.articleId).toBe("article-1"); + expect(notification.postId).toBeUndefined(); + }); + + it("should persist the new comment as the notification target", async () => { + vi.mocked(txCommentRepo.create).mockResolvedValueOnce( + buildComment({ + id: "new-comment-9", + postId: null, + articleId: "article-1", + authorId: COMMENTER, + }), + ); + + await useCase.execute({ + content: "Nice piece", + target: ARTICLE_TARGET, + authorId: COMMENTER, + }); + + const [notification] = vi.mocked(txNotificationRepo.create).mock + .calls[0]; + expect(notification.commentId).toBe("new-comment-9"); + expect(notification.referenceId).toBe("new-comment-9"); + }); + + it("should send the article slug so the client can build the URL", async () => { + await useCase.execute({ + content: "Nice piece", + target: ARTICLE_TARGET, + authorId: COMMENTER, + }); + + expect(realtimeSvc.emitToUser).toHaveBeenCalledWith( + AUTHOR, + "new-notification", + expect.objectContaining({ articleSlug: expect.any(String) }), + ); }); it("should populate referenceId with the new comment", async () => { diff --git a/tests/unit/core/use-cases/comment/create-comment.usecase.test.ts b/tests/unit/core/use-cases/comment/create-comment.usecase.test.ts index 3974f01e..625e69b7 100644 --- a/tests/unit/core/use-cases/comment/create-comment.usecase.test.ts +++ b/tests/unit/core/use-cases/comment/create-comment.usecase.test.ts @@ -131,6 +131,13 @@ describe("CreateCommentUseCase", () => { "new-notification", expect.objectContaining({ type: "COMMENT" }), ); + + const [notification] = vi.mocked(txNotificationRepo.create).mock + .calls[0]; + expect(notification.commentId).toBe("new-comment-1"); + expect(notification.postId).toBe("post-1"); + expect(notification.referenceId).toBe("new-comment-1"); + expect(notification.articleId).toBeUndefined(); }); it("should not send notification when commenter is the post author", async () => { diff --git a/tests/unit/core/use-cases/comment/like-comment.usecase.test.ts b/tests/unit/core/use-cases/comment/like-comment.usecase.test.ts index 9f6ee21a..c1377331 100644 --- a/tests/unit/core/use-cases/comment/like-comment.usecase.test.ts +++ b/tests/unit/core/use-cases/comment/like-comment.usecase.test.ts @@ -128,4 +128,46 @@ describe("LikeCommentUseCase", () => { }), ); }); + + it("should persist the comment and the post it lives under", async () => { + vi.mocked(txCommentRepo.findById).mockResolvedValue( + buildComment({ authorId: "comment-author", postId: "post-1" }), + ); + vi.mocked(txCommentRepo.hasUserLiked).mockResolvedValue(false); + vi.mocked(txCommentRepo.addLike).mockResolvedValue(undefined); + vi.mocked(txCommentRepo.incrementLikeCount).mockResolvedValue( + undefined, + ); + + await useCase.execute({ commentId: "comment-1", userId: "user-1" }); + + const [notification] = vi.mocked(txNotificationRepo.create).mock + .calls[0]; + expect(notification.commentId).toBe("comment-1"); + expect(notification.postId).toBe("post-1"); + expect(notification.referenceId).toBe("comment-1"); + expect(notification.articleId).toBeUndefined(); + }); + + it("should persist the article a liked comment lives under", async () => { + vi.mocked(txCommentRepo.findById).mockResolvedValue( + buildComment({ + authorId: "comment-author", + postId: null, + articleId: "article-7", + }), + ); + vi.mocked(txCommentRepo.hasUserLiked).mockResolvedValue(false); + vi.mocked(txCommentRepo.addLike).mockResolvedValue(undefined); + vi.mocked(txCommentRepo.incrementLikeCount).mockResolvedValue( + undefined, + ); + + await useCase.execute({ commentId: "comment-1", userId: "user-1" }); + + const [notification] = vi.mocked(txNotificationRepo.create).mock + .calls[0]; + expect(notification.articleId).toBe("article-7"); + expect(notification.postId).toBeUndefined(); + }); }); diff --git a/tests/unit/core/use-cases/post/like-post.usecase.test.ts b/tests/unit/core/use-cases/post/like-post.usecase.test.ts index 5100de4d..ec1b4a22 100644 --- a/tests/unit/core/use-cases/post/like-post.usecase.test.ts +++ b/tests/unit/core/use-cases/post/like-post.usecase.test.ts @@ -111,6 +111,21 @@ describe("LikePostUseCase", () => { ); }); + it("should persist the liked post on the notification so it can be opened", async () => { + vi.mocked(mockCtx.postRepository.findById).mockResolvedValue( + buildPost({ id: "post-1", author: { id: "author-1" } }), + ); + + await useCase.execute({ postId: "post-1", userId: "liker-99" }); + + const [notification] = vi.mocked(mockCtx.notificationRepository.create) + .mock.calls[0]; + expect(notification.postId).toBe("post-1"); + expect(notification.referenceId).toBe("post-1"); + expect(notification.articleId).toBeUndefined(); + expect(notification.commentId).toBeUndefined(); + }); + it("should not create notification when user likes their own post", async () => { vi.mocked(mockCtx.postRepository.findById).mockResolvedValue( buildPost({ author: { id: "user-1" } }), diff --git a/tests/unit/infrastructure/mappers/notification-prisma.mapper.test.ts b/tests/unit/infrastructure/mappers/notification-prisma.mapper.test.ts index 6982f988..46584e93 100644 --- a/tests/unit/infrastructure/mappers/notification-prisma.mapper.test.ts +++ b/tests/unit/infrastructure/mappers/notification-prisma.mapper.test.ts @@ -19,11 +19,15 @@ function makePrismaItem( recipientId: "user-1", issuerId: "user-2", referenceId: null, + postId: null, + articleId: null, + commentId: null, isRead: false, issuer: { username: "follower", profile: { avatarUrl: "uploads/avatar.jpg" }, }, + article: null, ...overrides, }; } @@ -68,6 +72,46 @@ describe("NotificationPrismaMapper", () => { expect(result.avatarUrl).toBe(""); }); + it("should map the id so a single notification can be addressed", () => { + const result = NotificationPrismaMapper.toDomain(makePrismaItem()); + + expect(result.id).toBe("notif-1"); + }); + + it("should map the deep-link target of a comment notification", () => { + const result = NotificationPrismaMapper.toDomain( + makePrismaItem({ + type: "COMMENT_LIKE" as PrismaNotificationItem["type"], + referenceId: "comment-9", + commentId: "comment-9", + postId: "post-42", + }), + ); + + expect(result.commentId).toBe("comment-9"); + expect(result.postId).toBe("post-42"); + expect(result.articleId).toBeUndefined(); + }); + + it("should map the article slug so the client can build its URL", () => { + const result = NotificationPrismaMapper.toDomain( + makePrismaItem({ + referenceId: "article-7", + articleId: "article-7", + article: { slug: "clean-architecture-in-practice" }, + }), + ); + + expect(result.articleId).toBe("article-7"); + expect(result.articleSlug).toBe("clean-architecture-in-practice"); + }); + + it("should leave articleSlug undefined when no article is linked", () => { + const result = NotificationPrismaMapper.toDomain(makePrismaItem()); + + expect(result.articleSlug).toBeUndefined(); + }); + it("should map referenceId when present", () => { const result = NotificationPrismaMapper.toDomain( makePrismaItem({ referenceId: "post-42" }), @@ -157,6 +201,40 @@ describe("NotificationPrismaMapper", () => { expect(result.referenceId).toBeUndefined(); }); + + it("should expose the notification id", () => { + const entity = NotificationPrismaMapper.toDomain(makePrismaItem()); + const result = NotificationPrismaMapper.toResponse(entity, CDN); + + expect(result.id).toBe("notif-1"); + }); + + it("should expose the whole destination of a comment notification", () => { + const entity = NotificationPrismaMapper.toDomain( + makePrismaItem({ + type: "COMMENT_REPLY" as PrismaNotificationItem["type"], + referenceId: "comment-9", + commentId: "comment-9", + articleId: "article-7", + article: { slug: "clean-architecture-in-practice" }, + }), + ); + const result = NotificationPrismaMapper.toResponse(entity, CDN); + + expect(result.commentId).toBe("comment-9"); + expect(result.articleId).toBe("article-7"); + expect(result.articleSlug).toBe("clean-architecture-in-practice"); + expect(result.postId).toBeUndefined(); + }); + + it("should leave the destination empty for a follow notification", () => { + const entity = NotificationPrismaMapper.toDomain(makePrismaItem()); + const result = NotificationPrismaMapper.toResponse(entity, CDN); + + expect(result.postId).toBeUndefined(); + expect(result.articleId).toBeUndefined(); + expect(result.commentId).toBeUndefined(); + }); }); describe("toPrisma", () => { @@ -184,6 +262,21 @@ describe("NotificationPrismaMapper", () => { expect(result.referenceId).toBeNull(); }); + it("should persist the target ids of a comment notification", () => { + const entity = NotificationPrismaMapper.toDomain( + makePrismaItem({ + referenceId: "comment-9", + commentId: "comment-9", + postId: "post-42", + }), + ); + const result = NotificationPrismaMapper.toPrisma(entity); + + expect(result.commentId).toBe("comment-9"); + expect(result.postId).toBe("post-42"); + expect(result.articleId).toBeNull(); + }); + it("should pass referenceId through when present", () => { const entity = NotificationPrismaMapper.toDomain( makePrismaItem({ referenceId: "post-42" }),