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" }),