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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 6 additions & 1 deletion src/context/application/queries/message.queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
},
Expand Down
9 changes: 8 additions & 1 deletion src/context/application/queries/message.queries.unit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
},
Expand Down
9 changes: 9 additions & 0 deletions src/context/application/queries/notification.contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
39 changes: 36 additions & 3 deletions src/context/application/queries/notification.queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<Entities.Notification> = { id: props.notification };
const where: ORM.FilterQuery<Entities.Notification> = {
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 });
Expand All @@ -25,8 +52,14 @@ export class NotificationQueries implements Queries.Notification.Contract {
const prefilter: ORM.ObjectQuery<Entities.Notification> = {};

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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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: {} },
Expand Down
9 changes: 8 additions & 1 deletion src/context/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { NotificationCategory, ChannelType } from "./enums";
import { NotificationCategory, ChannelType, MessageTemplate } from "./enums";

export { SYSTEM_REALM_ID } from "@monadiam/shared";

Expand All @@ -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 = {
Expand Down
6 changes: 4 additions & 2 deletions src/context/domain/services/notification.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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[] = [];

Expand Down
17 changes: 15 additions & 2 deletions src/context/infrastructure/repositories/notification.contract.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
declare namespace Repositories.Notification {
interface Contract extends Repositories.Base.Contract<Entities.Notification, Repositories.Mappers.Notification.Types> {}
interface Contract extends Repositories.Base.Contract<Entities.Notification, Repositories.Mappers.Notification.Types> {
count: Count.Signature;
}

interface QueryContract extends Pick<Contract, "findUniqueOrThrow" | "findMany"> {}
interface QueryContract extends Pick<Contract, "findUniqueOrThrow" | "findMany" | "count"> {}

namespace Count {
type Props = {
transaction?: ORM.EntityManager;
where: ORM.FilterQuery<Entities.Notification>;
};

type Result = Promise<number>;

type Signature = (props: Props) => Result;
}
}
12 changes: 12 additions & 0 deletions src/context/infrastructure/repositories/notification.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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);
}
}
}
31 changes: 30 additions & 1 deletion src/context/interface/controllers/notification.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/context/interface/dto/notification/index.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
12 changes: 12 additions & 0 deletions src/context/interface/dto/notification/res/unread-count.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading