diff --git a/package.json b/package.json index 576668d..3eec3f3 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "description": "Platform | Notifications: email via AWS SES, SMS via AWS SNS, in-app notifications, recipient preferences, dispatch retries and message status tracking.", "author": "Daniil Egorov (https://github.com/Lambdius)", "license": "SEE LICENSE IN LICENSE", - "version": "1.0.5", + "version": "1.0.6", "main": "dist/index.js", "engines": { "node": "24.18.0" diff --git a/src/context/application/queries/message.queries.ts b/src/context/application/queries/message.queries.ts index 74f98c6..e7aee82 100644 --- a/src/context/application/queries/message.queries.ts +++ b/src/context/application/queries/message.queries.ts @@ -2,6 +2,7 @@ import { Inject, Injectable, Scope } from "@nestjs/common"; import { MESSAGE_REPOSITORY } from "~context/infrastructure/repositories"; import { ChannelType, MessageStatus, QueryMode } from "~context/enums"; +import { OTP_MESSAGE_TEMPLATES } from "~context/constants"; @Injectable({ scope: Scope.DEFAULT }) export class MessageQueries implements Queries.Message.Contract { @@ -18,7 +19,11 @@ export class MessageQueries implements Queries.Message.Contract { if (props.mode === QueryMode.DEFAULT) { return this.messageRepository.findMany({ prefilter: { - notification: { id: props.notification, recipient: { account: props.actor } }, + notification: { + template: { $nin: [...OTP_MESSAGE_TEMPLATES] }, + recipient: { account: props.actor }, + id: props.notification, + }, channelType: ChannelType.IN_APP, status: MessageStatus.DELIVERED, }, diff --git a/src/context/application/queries/message.queries.unit.spec.ts b/src/context/application/queries/message.queries.unit.spec.ts index f5cb51a..5fa34f2 100644 --- a/src/context/application/queries/message.queries.unit.spec.ts +++ b/src/context/application/queries/message.queries.unit.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@jest/globals"; import { MessageQueriesUnitHelpers } from "~testing/unit/query-services/message.helpers"; import { QueryMode, ChannelType, MessageStatus } from "~context/enums"; +import { OTP_MESSAGE_TEMPLATES } from "~context/constants"; const PAGINATION: Pagination = { currentPage: 2, elementsPerPage: 10 }; const helpers = new MessageQueriesUnitHelpers(); @@ -30,7 +31,13 @@ describe("[Queries] - Message", () => { expect.objectContaining({ filters: {}, prefilter: { - notification: { id: ID, recipient: { account: ACTOR } }, + notification: { + id: ID, + recipient: { account: ACTOR }, + template: { + $nin: [...OTP_MESSAGE_TEMPLATES], + }, + }, channelType: ChannelType.IN_APP, status: MessageStatus.DELIVERED, }, diff --git a/src/context/application/queries/notification.contract.d.ts b/src/context/application/queries/notification.contract.d.ts index 5f0bbb7..c87bf25 100644 --- a/src/context/application/queries/notification.contract.d.ts +++ b/src/context/application/queries/notification.contract.d.ts @@ -5,10 +5,19 @@ declare global { interface Contract extends ControllerContract {} interface ControllerContract { + countUnread: CountUnread.Signature; findUnique: FindUnique.Signature; findMany: FindMany.Signature; } + namespace CountUnread { + type Props = { actor: string }; + + type Result = Promise<{ count: number }>; + + type Signature = (props: Props) => Result; + } + namespace FindUnique { type DefaultProps = { mode: QueryMode.DEFAULT; diff --git a/src/context/application/queries/notification.queries.ts b/src/context/application/queries/notification.queries.ts index 1f7281b..8be3551 100644 --- a/src/context/application/queries/notification.queries.ts +++ b/src/context/application/queries/notification.queries.ts @@ -2,6 +2,7 @@ import { Inject, Injectable, Scope } from "@nestjs/common"; import { NOTIFICATION_REPOSITORY } from "~context/infrastructure/repositories"; import { ChannelType, MessageStatus, QueryMode } from "~context/enums"; +import { OTP_MESSAGE_TEMPLATES } from "~context/constants"; @Injectable({ scope: Scope.DEFAULT }) export class NotificationQueries implements Queries.Notification.Contract { @@ -10,12 +11,38 @@ export class NotificationQueries implements Queries.Notification.Contract { private readonly notificationRepository: Repositories.Notification.QueryContract, ) {} + public async countUnread(props: Queries.Notification.CountUnread.Props): Queries.Notification.CountUnread.Result { + const count = await this.notificationRepository.count({ + where: { + template: { $nin: [...OTP_MESSAGE_TEMPLATES] }, + recipient: { account: props.actor }, + messages: { + $some: { + channelType: ChannelType.IN_APP, + status: MessageStatus.DELIVERED, + readAt: null, + }, + }, + }, + }); + + return { count }; + } + public findUnique(props: Queries.Notification.FindUnique.Props): Queries.Notification.FindUnique.Result { - const where: ORM.FilterQuery = { id: props.notification }; + const where: ORM.FilterQuery = { + id: props.notification, + }; if (props.mode === QueryMode.DEFAULT) { + where.template = { $nin: [...OTP_MESSAGE_TEMPLATES] }; where.recipient = { account: props.actor }; - where.messages = { channelType: ChannelType.IN_APP, status: MessageStatus.DELIVERED }; + where.messages = { + $some: { + channelType: ChannelType.IN_APP, + status: MessageStatus.DELIVERED, + }, + }; } return this.notificationRepository.findUniqueOrThrow({ where }); @@ -25,8 +52,14 @@ export class NotificationQueries implements Queries.Notification.Contract { const prefilter: ORM.ObjectQuery = {}; if (props.mode === QueryMode.DEFAULT) { + prefilter.template = { $nin: [...OTP_MESSAGE_TEMPLATES] }; prefilter.recipient = { account: props.actor }; - prefilter.messages = { channelType: ChannelType.IN_APP, status: MessageStatus.DELIVERED }; + prefilter.messages = { + $some: { + channelType: ChannelType.IN_APP, + status: MessageStatus.DELIVERED, + }, + }; } return this.notificationRepository.findMany({ diff --git a/src/context/application/queries/notification.queries.unit.spec.ts b/src/context/application/queries/notification.queries.unit.spec.ts index 27a0e4f..e620bb6 100644 --- a/src/context/application/queries/notification.queries.unit.spec.ts +++ b/src/context/application/queries/notification.queries.unit.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@jest/globals"; import { NotificationQueriesUnitHelpers } from "~testing/unit/query-services/notification.helpers"; import { QueryMode, ChannelType, MessageStatus } from "~context/enums"; +import { OTP_MESSAGE_TEMPLATES } from "~context/constants"; const PAGINATION: Pagination = { currentPage: 2, elementsPerPage: 10 }; const helpers = new NotificationQueriesUnitHelpers(); @@ -15,7 +16,12 @@ describe("[Queries] - Notification", () => { mode: QueryMode.DEFAULT, scope: { recipient: { account: ACTOR }, - messages: { channelType: ChannelType.IN_APP, status: MessageStatus.DELIVERED }, + template: { + $nin: [...OTP_MESSAGE_TEMPLATES], + }, + messages: { + $some: { channelType: ChannelType.IN_APP, status: MessageStatus.DELIVERED }, + }, }, }, { mode: QueryMode.MANAGE, scope: {} }, diff --git a/src/context/constants.ts b/src/context/constants.ts index e94e42b..dce6015 100644 --- a/src/context/constants.ts +++ b/src/context/constants.ts @@ -1,4 +1,4 @@ -import { NotificationCategory, ChannelType } from "./enums"; +import { NotificationCategory, ChannelType, MessageTemplate } from "./enums"; export { SYSTEM_REALM_ID } from "@monadiam/shared"; @@ -14,6 +14,13 @@ export const CONFIGURABLE_NOTIFICATION_CATEGORIES: readonly NotificationCategory export const CUSTOM_TEMPLATE = "CUSTOM"; +export const OTP_MESSAGE_TEMPLATES: readonly string[] = [ + MessageTemplate.ACCOUNT_VERIFICATION_OTP, + MessageTemplate.IDENTIFIER_VERIFICATION_OTP, + MessageTemplate.RESET_PASSWORD_OTP, + MessageTemplate.LOGIN_OTP, +]; + export const DEBOUNCED_CATEGORIES: readonly NotificationCategory[] = [NotificationCategory.INVITES]; export const CONSUMER_META: Extract.Meta = { diff --git a/src/context/domain/services/notification.service.ts b/src/context/domain/services/notification.service.ts index da79b97..dd472c7 100644 --- a/src/context/domain/services/notification.service.ts +++ b/src/context/domain/services/notification.service.ts @@ -2,7 +2,7 @@ import { Inject, Injectable, Scope } from "@nestjs/common"; import { NotificationCategory, ChannelType, MessageTemplate, PlatformService } from "~context/enums"; import { RECIPIENT_REPOSITORY, NOTIFICATION_REPOSITORY } from "~context/infrastructure/repositories"; -import { DUPLICATABLE_CHANNEL_TYPES } from "~context/constants"; +import { DUPLICATABLE_CHANNEL_TYPES, OTP_MESSAGE_TEMPLATES } from "~context/constants"; import { RECIPIENT_SERVICE, MESSAGE_SERVICE } from "./tokens"; import { Notification, Message } from "../entities"; @@ -43,7 +43,9 @@ export class NotificationService implements Services.Notification.Contract { transaction.persist(notification); - const channelTypes = this.resolveChannelTypes({ recipient, category: input.category }); + const channelTypes = OTP_MESSAGE_TEMPLATES.includes(input.template) + ? [ChannelType.EMAIL] + : this.resolveChannelTypes({ recipient, category: input.category }); const channels = recipient.channels.getItems(); const messages: Entities.Message[] = []; diff --git a/src/context/infrastructure/repositories/notification.contract.d.ts b/src/context/infrastructure/repositories/notification.contract.d.ts index f7f9d5f..abcdbb8 100644 --- a/src/context/infrastructure/repositories/notification.contract.d.ts +++ b/src/context/infrastructure/repositories/notification.contract.d.ts @@ -1,5 +1,18 @@ declare namespace Repositories.Notification { - interface Contract extends Repositories.Base.Contract {} + interface Contract extends Repositories.Base.Contract { + count: Count.Signature; + } - interface QueryContract extends Pick {} + interface QueryContract extends Pick {} + + namespace Count { + type Props = { + transaction?: ORM.EntityManager; + where: ORM.FilterQuery; + }; + + type Result = Promise; + + type Signature = (props: Props) => Result; + } } diff --git a/src/context/infrastructure/repositories/notification.repository.ts b/src/context/infrastructure/repositories/notification.repository.ts index 4a7deac..0c1e402 100644 --- a/src/context/infrastructure/repositories/notification.repository.ts +++ b/src/context/infrastructure/repositories/notification.repository.ts @@ -3,6 +3,7 @@ import { Injectable, Scope } from "@nestjs/common"; import { Notification } from "~context/domain/entities"; import { BaseRepository } from "~common/mixins"; +import { ExceptionMapper } from "~common/exceptions"; import { NotificationMapper } from "../mappers"; @@ -20,4 +21,15 @@ export class NotificationRepository ) { super(); } + + public async count(props: Repositories.Notification.Count.Props): Repositories.Notification.Count.Result { + try { + const { transaction, where } = props; + const entityManager = transaction ?? this.readManager.fork(); + + return await entityManager.count(Notification, where); + } catch (error) { + throw ExceptionMapper.fromORM(error, this.resource); + } + } } diff --git a/src/context/interface/controllers/notification.controller.ts b/src/context/interface/controllers/notification.controller.ts index 9fb339c..e725ddf 100644 --- a/src/context/interface/controllers/notification.controller.ts +++ b/src/context/interface/controllers/notification.controller.ts @@ -5,7 +5,14 @@ import { Extract, FormatResponse, RequirePermission, Swagger } from "~common/dec import { NOTIFICATION_QUERIES } from "~context/application/queries"; import { PermissionCode } from "~context/enums"; -import { GetByIdQueryDTO, GetListQueryDTO, GetListBodyDTO, NotificationDTO, ListDTO } from "../dto/notification"; +import { + GetByIdQueryDTO, + GetListQueryDTO, + NotificationDTO, + GetListBodyDTO, + UnreadCountDTO, + ListDTO, +} from "../dto/notification"; const { NOTIFICATION_READ_PERSONAL } = PermissionCode; const { @@ -27,6 +34,28 @@ export class NotificationController { private readonly notificationQueries: Queries.Notification.ControllerContract, ) {} + @HttpCode(OK) + @Get("unread-count") + @FormatResponse(UnreadCountDTO) + @RequirePermission(NOTIFICATION_READ_PERSONAL) + @ApiOperation({ + summary: "Returns the number of unread in-app notifications for the current account", + security: [{ identity: [] }], + }) + @Swagger.Exceptions( + INTERNAL_SERVER_ERROR, + UNPROCESSABLE_ENTITY, + SERVICE_UNAVAILABLE, + REQUEST_TIMEOUT, + UNAUTHORIZED, + FORBIDDEN, + ) + public countUnread( + @Extract.Session() { account: actor }: Extract.Session.Auth, + ): Queries.Notification.CountUnread.Result { + return this.notificationQueries.countUnread({ actor }); + } + @Get() @HttpCode(OK) @FormatResponse(NotificationDTO) diff --git a/src/context/interface/dto/notification/index.ts b/src/context/interface/dto/notification/index.ts index 9d6c883..1c03ad4 100644 --- a/src/context/interface/dto/notification/index.ts +++ b/src/context/interface/dto/notification/index.ts @@ -1,6 +1,7 @@ export { ManageGetListQueryDTO, ManageGetListBodyDTO, GetListQueryDTO, GetListBodyDTO } from "./req/get-list.dto"; export { ManageGetByIdQueryDTO, GetByIdQueryDTO } from "./req/get-by-id.dto"; +export { UnreadCountDTO } from "./res/unread-count.dto"; export { NotificationDTO } from "./res/entity.dto"; export { ListDTO } from "./res/list.dto"; diff --git a/src/context/interface/dto/notification/res/unread-count.dto.ts b/src/context/interface/dto/notification/res/unread-count.dto.ts new file mode 100644 index 0000000..200307d --- /dev/null +++ b/src/context/interface/dto/notification/res/unread-count.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty, ApiSchema } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; + +import { Validator } from "~common/validator"; + +@ApiSchema({ name: "NotificationUnreadCount" }) +export class UnreadCountDTO { + @Expose() + @Validator.IsInt() + @ApiProperty({ required: true, type: Number }) + declare public count: number; +}