Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
9 changes: 5 additions & 4 deletions prisma/models/article.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
23 changes: 22 additions & 1 deletion prisma/models/notification.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,38 @@ 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())

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")
}
14 changes: 8 additions & 6 deletions prisma/models/post.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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")

Expand Down
73 changes: 70 additions & 3 deletions src/core/domain/entities/notification.entity.ts
Original file line number Diff line number Diff line change
@@ -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
*
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/core/domain/interfaces/notification-props.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;

Expand Down
3 changes: 3 additions & 0 deletions src/core/ports/services/realtime.port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export class LikeArticleUseCase {
article.author.id,
input.userId,
NotificationType.LIKE,
input.articleId,
{ articleId: input.articleId },
);

await ctx.notificationRepository.create(notification);
Expand All @@ -66,6 +66,7 @@ export class LikeArticleUseCase {
type: NotificationType.LIKE,
issuerId: input.userId,
articleId: input.articleId,
articleSlug: article.slug,
referenceId: input.articleId,
},
);
Expand Down
25 changes: 14 additions & 11 deletions src/core/use-cases/comment/create-comment/create-comment.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
): 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);
Expand All @@ -67,7 +67,7 @@ export class CreateCommentUseCase {
);
}

return article.author.id;
return { authorId: article.author.id, slug: article.slug };
}

/**
Expand All @@ -81,11 +81,8 @@ export class CreateCommentUseCase {
async execute(input: CreateCommentUseCaseInput): Promise<Comment> {
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;
Expand Down Expand Up @@ -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);
Expand All @@ -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,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/core/use-cases/post/like-post/like-post.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export class LikePostUseCase {
post.author.id,
input.userId,
NotificationType.LIKE,
{ postId: input.postId },
);

await ctx.notificationRepository.create(notification);
Expand All @@ -62,6 +63,7 @@ export class LikePostUseCase {
type: NotificationType.LIKE,
issuerId: input.userId,
postId: input.postId,
referenceId: input.postId,
},
);
}
Expand Down
10 changes: 10 additions & 0 deletions src/http/types/schemas/notification/get-notification.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand Down
Loading