Skip to content
Closed
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,11 @@
-- Quoting someone's post now notifies them.
--
-- Postgres refuses ALTER TYPE ... ADD VALUE inside a transaction block before
-- version 12, and Prisma runs every migration in one. From 12 onwards it is
-- allowed, with the single condition that the new value is not *used* in the
-- same transaction - this migration only adds it, so nothing here writes a
-- QUOTE row. This is the first enum change in the repository; the reasoning
-- lives here so the next one does not have to rediscover it.

-- AlterEnum
ALTER TYPE "public"."NotificationType" ADD VALUE 'QUOTE';
1 change: 1 addition & 0 deletions prisma/models/notification.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ enum NotificationType {
LIKE
COMMENT_LIKE
COMMENT_REPLY
QUOTE
}

model Notification {
Expand Down
5 changes: 5 additions & 0 deletions src/core/domain/enums/notification-type.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,9 @@ export enum NotificationType {
* A reply to one of the user's comments
*/
COMMENT_REPLY = "COMMENT_REPLY",

/**
* Notification when a user quotes another user's post
*/
QUOTE = "QUOTE",
}
2 changes: 2 additions & 0 deletions src/core/use-cases/notification/notify-quoted-author/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { NotifyQuotedAuthorUseCase } from "./notify-quoted-author.usecase";
export type { NotifyQuotedAuthorInput } from "./notify-quoted-author.input";
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Input for the NotifyQuotedAuthorUseCase.
*/
export interface NotifyQuotedAuthorInput {
/** The post that was just published as a quote, and where the notification leads. */
quotePostId: string;

/** The post it quotes, whose author is notified. */
quotedPostId: string;

/** The account that published the quote, and the issuer of the notification. */
issuerId: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Notification } from "@core/domain/entities/notification.entity";
import { NotificationType } from "@core/domain/enums/notification-type.enum";
import type { IPostRepository } from "@core/ports/repositories/post.repository";
import type { INotificationRepository } from "@core/ports/repositories/notification.repository";
import type { RealtimePort } from "@core/ports/services/realtime.port";
import type { NotifyQuotedAuthorInput } from "./notify-quoted-author.input";

/**
* Use case for telling an author that one of their posts was quoted.
*
* Kept separate from post creation for the same reason as
* {@link NotifyNewPostUseCase}: a single caller-agnostic entry point, so
* moving it onto a queue later is a change of caller rather than a rewrite.
*/
export class NotifyQuotedAuthorUseCase {
/**
* Creates a new instance of NotifyQuotedAuthorUseCase.
*
* @param postRepository - Repository used to resolve the quoted post's author
* @param notificationRepository - Repository for persisting the notification
* @param realtimeService - Service for pushing the notification live
*/
constructor(
private readonly postRepository: IPostRepository,
private readonly notificationRepository: INotificationRepository,
private readonly realtimeService: RealtimePort,
) {}

/**
* Notifies the author of the quoted post.
*
* @param input - The new quote, the post it quotes, and who published it
* @returns Promise<number> The number of people notified: 1, or 0
*
* @remarks
* The notification points at the **quote**, not at the post being quoted:
* the recipient already knows their own post, what they want to open is
* what somebody said about it, and the quote carries the original as its
* card anyway. That also buys the cleanup for free - `Notification.post`
* cascades, so deleting the quote takes its notification with it.
*
* Returns 0 without writing anything when the quoted post is already gone
* (a narrow race between the commit and this call) or when an account
* quotes itself, which is not news to anyone.
*/
async execute(input: NotifyQuotedAuthorInput): Promise<number> {
const quotedPost = await this.postRepository.findById(
input.quotedPostId,
);
if (!quotedPost) return 0;

const recipientId = quotedPost.author.id;
if (recipientId === input.issuerId) return 0;

await this.notificationRepository.create(
Notification.create(
recipientId,
input.issuerId,
NotificationType.QUOTE,
{ postId: input.quotePostId },
),
);

this.realtimeService.emitToUser(recipientId, "new-notification", {
type: NotificationType.QUOTE,
issuerId: input.issuerId,
postId: input.quotePostId,
referenceId: input.quotePostId,
});

return 1;
}
}
25 changes: 22 additions & 3 deletions src/core/use-cases/post/create-post/create-post.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Post } from "@core/domain/entities/post.entity";
import type { IUserRepository } from "@core/ports/repositories/user.repository";
import type { LoggerPort } from "@core/ports/services/logger.port";
import type { NotifyNewPostUseCase } from "@core/use-cases/notification/notify-new-post";
import type { NotifyQuotedAuthorUseCase } from "@core/use-cases/notification/notify-quoted-author";
import { PostType } from "@core/domain/enums";
import { ForbiddenError } from "@core/errors/common/forbidden.error";
import { NotFoundError } from "@core/errors/common/not-found.error";
Expand All @@ -23,13 +24,15 @@ export class CreatePostUseCase {
* @param cacheService - Service for cache operations
* @param userRepository - Repository for managing user data
* @param notifyNewPostUseCase - Use case that fans the post out to followers
* @param notifyQuotedAuthorUseCase - Use case that tells an author their post was quoted
* @param logger - Service for logging operations
*/
constructor(
private readonly transactionService: TransactionPort,
private readonly cacheService: CachePort,
private readonly userRepository: IUserRepository,
private readonly notifyNewPostUseCase: NotifyNewPostUseCase,
private readonly notifyQuotedAuthorUseCase: NotifyQuotedAuthorUseCase,
private readonly logger: LoggerPort,
) {}

Expand Down Expand Up @@ -58,9 +61,10 @@ export class CreatePostUseCase {
* so do the cache purge and the fan-out, which must not hold the write
* open or roll it back.
*
* Followers are notified after the post is committed, deliberately
* outside the caller's critical path: the post is the thing worth keeping,
* so a fan-out failure is logged rather than allowed to fail the request.
* Followers and, for a quote, the quoted author are notified after the
* post is committed, deliberately outside the caller's critical path: the
* post is the thing worth keeping, so a notification failure is logged
* rather than allowed to fail the request or roll the write back.
*/
async execute(input: CreatePostInput): Promise<Post> {
if ([PostType.SYSTEM_UPDATE, PostType.TECH_NEWS].includes(input.type)) {
Expand Down Expand Up @@ -120,6 +124,21 @@ export class CreatePostUseCase {
);
});

if (input.quotedPostId) {
void this.notifyQuotedAuthorUseCase
.execute({
quotePostId: rawPost.id,
quotedPostId: input.quotedPostId,
issuerId: input.authorId,
})
.catch((err: unknown) => {
this.logger.error(
{ err, postId: rawPost.id },
"Failed to notify the quoted author",
);
});
}

return rawPost;
}
}
8 changes: 8 additions & 0 deletions src/http/plugins/di/use-cases.di.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { GetUnreadNotificationCountUseCase } from "@core/use-cases/notification/
import { PurgeExpiredNotificationsUseCase } from "@core/use-cases/notification/purge-expired";
import { CreatePostUseCase } from "@core/use-cases/post/create-post";
import { NotifyNewPostUseCase } from "@core/use-cases/notification/notify-new-post";
import { NotifyQuotedAuthorUseCase } from "@core/use-cases/notification/notify-quoted-author";
import { UploadPostMediaUseCase } from "@core/use-cases/post/upload-post-media";
import { GetPostsUseCase } from "@core/use-cases/post/get-posts";
import { DeletePostUseCase } from "@core/use-cases/post/delete-post";
Expand Down Expand Up @@ -325,13 +326,15 @@ export const useCasesModule = {
cacheService,
userRepository,
notifyNewPostUseCase,
notifyQuotedAuthorUseCase,
logger,
) =>
new CreatePostUseCase(
transactionService,
cacheService,
userRepository,
notifyNewPostUseCase,
notifyQuotedAuthorUseCase,
logger,
),
).singleton(),
Expand All @@ -341,6 +344,11 @@ export const useCasesModule = {
*/
notifyNewPostUseCase: asClass(NotifyNewPostUseCase).singleton(),

/**
* Use case for telling an author that one of their posts was quoted
*/
notifyQuotedAuthorUseCase: asClass(NotifyQuotedAuthorUseCase).singleton(),

/**
* Use case for uploading post media files
*/
Expand Down
164 changes: 164 additions & 0 deletions tests/e2e/notification/quote.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { authRequest, parseBody, request } from "../setup";
import { beforeAll, describe, expect, it } from "vitest";

interface NotificationItem {
id: string;
type: string;
issuerId: string;
// Both are Optional in the response schema, so the key is absent rather
// than null on a notification that points at nothing.
postId?: string;
referenceId?: string;
isRead: boolean;
}

/**
* E2E tests for QUOTE notifications.
*
* Being quoted is a louder signal than a like: somebody has said something
* about your post to their own followers. The notification leads to the
* quote rather than to the post being quoted, because the recipient already
* knows their own post.
*/
describe("QUOTE notifications", () => {
const ts = Date.now();
const author = {
email: `nq-author-${ts}@test.com`,
password: "password123",
username: `nqa${ts}`,
};
const quoter = {
email: `nq-quoter-${ts}@test.com`,
password: "password123",
username: `nqq${ts}`,
};

let authorToken = "";
let quoterToken = "";
let quoterId = "";
let originalPostId = "";

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

async function notificationsOf(token: string): Promise<NotificationItem[]> {
const response = await authRequest(token, {
method: "GET",
url: "/notifications?page=1&limit=50",
});
return parseBody<{ data: NotificationItem[] }>(response).data;
}

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

async function createPost(
token: string,
payload: Record<string, unknown>,
): Promise<string> {
const response = await authRequest(token, {
method: "POST",
url: "/posts",
payload,
});
expect(response.statusCode).toBe(201);
return parseBody<{ data: { id: string } }>(response).data.id;
}

/**
* Registers an author and a quoter, and leaves the author with one post
* for the quoter to point at.
*/
beforeAll(async () => {
await request({
method: "POST",
url: "/auth/register",
payload: author,
});
const quoterRes = await request({
method: "POST",
url: "/auth/register",
payload: quoter,
});
quoterId = parseBody<{ data: { id: string } }>(quoterRes).data.id;

authorToken = await login(author);
quoterToken = await login(quoter);

originalPostId = await createPost(authorToken, {
content: "A post worth quoting",
});
});

it("should notify the author, pointing at the quote", async () => {
const before = await unreadCountOf(authorToken);

const quoteId = await createPost(quoterToken, {
content: "Adding my two cents",
quotedPostId: originalPostId,
});

const notifications = await notificationsOf(authorToken);
const quoteNotification = notifications.find(
(item) => item.type === "QUOTE" && item.postId === quoteId,
);

expect(quoteNotification).toBeDefined();
expect(quoteNotification?.issuerId).toBe(quoterId);
expect(quoteNotification?.referenceId).toBe(quoteId);
expect(quoteNotification?.isRead).toBe(false);
expect(await unreadCountOf(authorToken)).toBe(before + 1);
});

it("should not notify the quoter about their own quote", async () => {
const notifications = await notificationsOf(quoterToken);

expect(notifications.some((item) => item.type === "QUOTE")).toBe(false);
});

it("should stay silent when an account quotes itself", async () => {
const before = await unreadCountOf(authorToken);

await createPost(authorToken, {
content: "Quoting myself",
quotedPostId: originalPostId,
});

expect(await unreadCountOf(authorToken)).toBe(before);
});

it("should take the notification with the quote when it is deleted", async () => {
// Notification.post cascades, so nothing has to clean this up by hand.
const quoteId = await createPost(quoterToken, {
content: "A quote that will not last",
quotedPostId: originalPostId,
});

const notified = await notificationsOf(authorToken);
expect(notified.some((item) => item.postId === quoteId)).toBe(true);

const deleteRes = await authRequest(quoterToken, {
method: "DELETE",
url: `/posts/${quoteId}`,
});
expect(deleteRes.statusCode).toBe(204);

const after = await notificationsOf(authorToken);
expect(after.some((item) => item.postId === quoteId)).toBe(false);
});
});
8 changes: 6 additions & 2 deletions tests/unit/core/domain/enums/enums.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,12 @@ describe("Domain Enums", () => {
expect(NotificationType.COMMENT_REPLY).toBe("COMMENT_REPLY");
});

it("should have exactly 6 values", () => {
expect(Object.keys(NotificationType)).toHaveLength(6);
it("should have QUOTE value", () => {
expect(NotificationType.QUOTE).toBe("QUOTE");
});

it("should have exactly 7 values", () => {
expect(Object.keys(NotificationType)).toHaveLength(7);
});
});

Expand Down
Loading