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
12 changes: 12 additions & 0 deletions src/core/ports/repositories/notification.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ export interface INotificationRepository {
*/
countByUserId(userId: string): Promise<number>;

/**
* Marks a single notification as read.
*
* Scoped to the recipient on purpose: a notification that belongs to
* somebody else must be indistinguishable from one that does not exist.
*
* @param notificationId - The unique identifier of the notification.
* @param recipientId - The user the notification must belong to.
* @returns True when a notification was updated, false when none matched.
*/
markAsRead(notificationId: string, recipientId: string): Promise<boolean>;

/**
* Marks all notifications for a specific user as read.
* @param userId - The unique identifier of the user.
Expand Down
6 changes: 6 additions & 0 deletions src/core/use-cases/notification/mark-one/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* This module contains the use case for marking a single notification as
* read, which is what a client calls when the user taps one.
*/
export { MarkNotificationAsReadUseCase } from "./mark-notification-as-read.usecase";
export type { MarkNotificationAsReadUseCaseInput } from "./mark-notification-as-read-usecase.input";
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Input interface for marking a single notification as read.
*/
export interface MarkNotificationAsReadUseCaseInput {
/**
* The ID of the notification to mark as read.
*/
notificationId: string;

/**
* The ID of the user the notification must belong to.
*/
userId: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { INotificationRepository } from "@core/ports/repositories/notification.repository";
import { NotFoundError } from "@core/errors";
import type { MarkNotificationAsReadUseCaseInput } from "./mark-notification-as-read-usecase.input";

/**
* Use case for marking one notification as read.
*
* This is what a client calls when the user taps a notification, so the
* unread badge drops by one instead of the whole list being cleared.
*/
export class MarkNotificationAsReadUseCase {
/**
* Creates a new instance of MarkNotificationAsReadUseCase.
*
* @param notificationRepository - Repository for managing notifications
*/
constructor(
private readonly notificationRepository: INotificationRepository,
) {}

/**
* Executes the mark as read process.
*
* @param input - The notification to mark and the user it must belong to
* @returns Promise<void> - Resolves once the notification is read
*
* @throws NotFoundError - When the notification does not exist or belongs
* to somebody else. The two cases answer identically on purpose: telling
* them apart would confirm that a notification id is real.
*/
async execute(input: MarkNotificationAsReadUseCaseInput): Promise<void> {
const updated = await this.notificationRepository.markAsRead(
input.notificationId,
input.userId,
);

if (!updated) {
throw new NotFoundError("Notification not found.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Input interface for reading a user's unread notification count.
*/
export interface GetUnreadNotificationCountInput {
/**
* The ID of the user whose unread notifications are counted.
*/
userId: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { INotificationRepository } from "@core/ports/repositories/notification.repository";
import type { GetUnreadNotificationCountInput } from "./get-unread-notification-count-usecase.input";

/**
* Use case for reading how many notifications a user has not read yet.
*
* Backs the unread badge, which would otherwise force the client to page
* through the whole list and count client-side.
*/
export class GetUnreadNotificationCountUseCase {
/**
* Creates a new instance of GetUnreadNotificationCountUseCase.
*
* @param notificationRepository - Repository for managing notifications
*/
constructor(
private readonly notificationRepository: INotificationRepository,
) {}

/**
* Executes the unread count lookup.
*
* @param input - The input containing the ID of the user to count for
* @returns Promise<number> The number of unread notifications
*/
async execute(input: GetUnreadNotificationCountInput): Promise<number> {
return await this.notificationRepository.getUnreadCount(input.userId);
}
}
6 changes: 6 additions & 0 deletions src/core/use-cases/notification/unread-count/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* This module contains the use case for reading a user's unread
* notification count, which backs the unread badge.
*/
export { GetUnreadNotificationCountUseCase } from "./get-unread-notification-count.usecase";
export type { GetUnreadNotificationCountInput } from "./get-unread-notification-count-usecase.input";
39 changes: 38 additions & 1 deletion src/http/controllers/notification.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@ import type { FastifyReply, FastifyRequest } from "fastify";
import type { GetUserNotificationUseCase } from "@core/use-cases/notification/get-user";
import type { GetNotificationsQuery } from "@typings/schemas/notification/get-notification.schema";
import type { MarkAllNotificationsAsReadUseCase } from "@core/use-cases/notification/mark-all";
import type { MarkNotificationAsReadUseCase } from "@core/use-cases/notification/mark-one";
import type { GetUnreadNotificationCountUseCase } from "@core/use-cases/notification/unread-count";
import type { NotificationIdParams } from "@typings/schemas/notification/get-notification.schema";
import { NotificationPrismaMapper } from "@infrastructure/persistence/mappers/notification-prisma.mapper";

export class NotificationController {
constructor(
private readonly getUserNotificationsUseCase: GetUserNotificationUseCase,
private readonly markAllReadUseCase: MarkAllNotificationsAsReadUseCase,
private readonly markNotificationReadUseCase: MarkNotificationAsReadUseCase,
private readonly getUnreadNotificationCountUseCase: GetUnreadNotificationCountUseCase,
) {}

async getNotifications(
Expand Down Expand Up @@ -42,13 +47,45 @@ export class NotificationController {
});
}

async getUnreadCount(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const userId = request.user.id;

const count = await this.getUnreadNotificationCountUseCase.execute({
userId,
});

return reply.status(200).send({
data: { count },
meta: {
timestamp: new Date().toISOString(),
},
});
}

async markAsRead(
request: FastifyRequest<{ Params: NotificationIdParams }>,
reply: FastifyReply,
): Promise<void> {
const userId = request.user.id;

await this.markNotificationReadUseCase.execute({
notificationId: request.params.id,
userId,
});

return reply.status(204).send();
}

async markAllAsRead(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const userId = request.user.id;

await this.markAllReadUseCase.execute(userId);
await this.markAllReadUseCase.execute({ userId });

return reply.status(200).send({
meta: {
Expand Down
16 changes: 16 additions & 0 deletions src/http/plugins/di/use-cases.di.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import { GetFollowersUseCase } from "@core/use-cases/follow-user/get-followers";
import { GetFollowingUseCase } from "@core/use-cases/follow-user/get-following";
import { GetUserNotificationUseCase } from "@core/use-cases/notification/get-user";
import { MarkAllNotificationsAsReadUseCase } from "@core/use-cases/notification/mark-all";
import { MarkNotificationAsReadUseCase } from "@core/use-cases/notification/mark-one";
import { GetUnreadNotificationCountUseCase } from "@core/use-cases/notification/unread-count";
import { PurgeExpiredNotificationsUseCase } from "@core/use-cases/notification/purge-expired";
import { CreatePostUseCase } from "@core/use-cases/post/create-post";
import { UploadPostMediaUseCase } from "@core/use-cases/post/upload-post-media";
Expand Down Expand Up @@ -292,6 +294,20 @@ export const useCasesModule = {
GetUserNotificationUseCase,
).singleton(),

/**
* Use case for reading the unread notification count
*/
getUnreadNotificationCountUseCase: asClass(
GetUnreadNotificationCountUseCase,
).singleton(),

/**
* Use case for marking a single notification as read
*/
markNotificationReadUseCase: asClass(
MarkNotificationAsReadUseCase,
).singleton(),

/**
* Use case for marking all notifications as read
*/
Expand Down
35 changes: 34 additions & 1 deletion src/http/routes/notification.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
*
* This module defines API endpoints for notification management including:
* - Retrieving user notifications with pagination and filtering
* - Marking all notifications as read
* - Reading the unread count that backs the notification badge
* - Marking a single notification, or all of them, as read
*
* @author TDN Team
* @version 1.0.0
Expand All @@ -16,6 +17,10 @@ import {
GetNotificationsResponseSchema,
type GetNotificationsResponse,
MarkAllReadResponseSchema,
NotificationIdParamsSchema,
type NotificationIdParams,
UnreadCountResponseSchema,
type UnreadCountResponse,
} from "@typings/schemas/notification/get-notification.schema";
import type { FastifyInstance } from "fastify";

Expand Down Expand Up @@ -46,6 +51,21 @@ export default function notificationRoutes(fastify: FastifyInstance): void {
notificationController.getNotifications.bind(notificationController),
);

fastify.get<{ Reply: { 200: UnreadCountResponse } }>(
"/notifications/unread-count",
{
onRequest: [fastify.authenticate],
schema: {
response: { 200: UnreadCountResponseSchema },
tags: ["Notification"],
},
config: { rateLimit: RateLimitPolicies.STANDARD },
},
notificationController.getUnreadCount.bind(notificationController),
);

// Declared before "/notifications/:id/read" only for readability: the
// static "read-all" segment outscores the ":id" parameter either way.
fastify.patch(
"/notifications/read-all",
{
Expand All @@ -58,4 +78,17 @@ export default function notificationRoutes(fastify: FastifyInstance): void {
},
notificationController.markAllAsRead.bind(notificationController),
);

fastify.patch<{ Params: NotificationIdParams; Reply: { 204: void } }>(
"/notifications/:id/read",
{
onRequest: [fastify.authenticate],
schema: {
params: NotificationIdParamsSchema,
tags: ["Notification"],
},
config: { rateLimit: RateLimitPolicies.STANDARD },
},
notificationController.markAsRead.bind(notificationController),
);
}
2 changes: 1 addition & 1 deletion src/http/types/fastify-awilix.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { RefreshTokenPurgeScheduler } from "@infrastructure/jobs/refresh-to
import type { ProfileController } from "@services/profile.controller";
import type { FollowUserController } from "@services/follow-user.controller";
import type { WebSocketManager } from "@infrastructure/realtime/websocket/websocket-manager";
import type NotificationController from "@services/notification.controller";
import type { NotificationController } from "@controllers/notification.controller";
import type { NotificationPurgeScheduler } from "@infrastructure/jobs/notification/notification-purge.scheduler";
import type PostController from "@services/post.controller";
import type { CommentController } from "@controllers/comment.controller";
Expand Down
12 changes: 10 additions & 2 deletions src/http/types/fastify-jwt.d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import type { UserPayload } from "@core/interfaces/user-payload.interface";
import type {
RecoveryPayload,
UserPayload,
} from "@core/ports/services/auth-token.port";
import "@fastify/jwt";

declare module "@fastify/jwt" {
interface FastifyJWT {
payload: UserPayload;
// Two kinds of token are signed with the same secret: the access token
// that identifies a user, and the short-lived account recovery token.
payload: UserPayload | RecoveryPayload;

// Only an access token ever reaches `request.user`, because the
// recovery token is verified explicitly by the recovery use case.
user: UserPayload;
}
}
15 changes: 15 additions & 0 deletions src/http/types/schemas/notification/get-notification.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,18 @@ export type GetNotificationsResponse = Static<

export const MarkAllReadResponseSchema = MetaOnlyResponseSchema;
export type MarkAllReadResponse = Static<typeof MarkAllReadResponseSchema>;

export const NotificationIdParamsSchema = Type.Object({
id: Type.String({ format: "uuid", description: "Notification ID" }),
});
export type NotificationIdParams = Static<typeof NotificationIdParamsSchema>;

export const UnreadCountResponseSchema = FBType.Object({
data: FBType.Object({
count: FBType.Number(),
}),
meta: FBType.Object({
timestamp: FBType.String(),
}),
});
export type UnreadCountResponse = Static<typeof UnreadCountResponseSchema>;
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,21 @@ export class PrismaNotificationRepository implements INotificationRepository {
});
}

async markAsRead(
notificationId: string,
recipientId: string,
): Promise<boolean> {
// updateMany rather than update: it lets the recipient be part of the
// filter, so another user's notification simply matches nothing
// instead of being updated or leaking its existence through an error.
const result = await this.prisma.notification.updateMany({
where: { id: notificationId, recipientId },
data: { isRead: true },
});

return result.count > 0;
}

async markAllAsRead(userId: string): Promise<void> {
await this.prisma.notification.updateMany({
where: {
Expand Down
Loading