diff --git a/.env.local.example b/.env.local.example index 1188233..40d1359 100644 --- a/.env.local.example +++ b/.env.local.example @@ -25,7 +25,7 @@ ORGANIZATION_SERVICE_URL="http://localhost:4004" HR_SERVICE_URL="http://localhost:4005" DOCS_OAUTH_CLIENT_ID="00000003-0000-4000-8000-130000000002" ALLOWED_SERVICE_ORIGINS='["http://localhost:4001","http://localhost:4002","http://localhost:4003","http://localhost:4004","http://localhost:4005"]' -ALLOWED_UI_ORIGINS='[]' +ALLOWED_UI_ORIGINS='["http://localhost:3000"]' # --------------------------------------------------------------------------- # OpenTelemetry diff --git a/.env.stand.example b/.env.stand.example index 81d4a2b..379f5d4 100644 --- a/.env.stand.example +++ b/.env.stand.example @@ -24,7 +24,7 @@ ORGANIZATION_SERVICE_URL="http://localhost:4004" HR_SERVICE_URL="http://localhost:4005" DOCS_OAUTH_CLIENT_ID="00000003-0000-4000-8000-130000000002" ALLOWED_SERVICE_ORIGINS='["http://localhost:4001","http://localhost:4002","http://localhost:4003","http://localhost:4004","http://localhost:4005"]' -ALLOWED_UI_ORIGINS='[]' +ALLOWED_UI_ORIGINS='["http://localhost:3000"]' # --------------------------------------------------------------------------- # OpenTelemetry diff --git a/package.json b/package.json index 3eec3f3..99fc4b6 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.6", + "version": "1.0.7", "main": "dist/index.js", "engines": { "node": "24.18.0" @@ -69,7 +69,7 @@ "@mikro-orm/nestjs": "7.0.2", "@mikro-orm/postgresql": "7.1.13", "@mikro-orm/seeder": "7.1.13", - "@monadiam/shared": "github:MonadIAM/shared#v3.1.0", + "@monadiam/shared": "github:MonadIAM/shared#v3.1.1", "@nest-lab/throttler-storage-redis": "1.2.0", "@nestjs/bullmq": "11.0.5", "@nestjs/common": "11.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f6100e..76b8a92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,8 +54,8 @@ importers: specifier: 7.1.13 version: 7.1.13(@mikro-orm/core@7.1.13) '@monadiam/shared': - specifier: github:MonadIAM/shared#v3.1.0 - version: https://codeload.github.com/MonadIAM/shared/tar.gz/5cb719bd74338d05a5c25961ea864605b89a7864 + specifier: github:MonadIAM/shared#v3.1.1 + version: https://codeload.github.com/MonadIAM/shared/tar.gz/e4bdb27ad3ff42798bf8a57ad7789baca4e04664 '@nest-lab/throttler-storage-redis': specifier: 1.2.0 version: 1.2.0(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.1)(@nestjs/throttler@6.5.0(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.1)(reflect-metadata@0.2.2))(ioredis@6.0.0(supports-color@8.1.1))(reflect-metadata@0.2.2) @@ -1186,9 +1186,9 @@ packages: engines: {node: '>=22.0.0'} hasBin: true - '@monadiam/shared@https://codeload.github.com/MonadIAM/shared/tar.gz/5cb719bd74338d05a5c25961ea864605b89a7864': - resolution: {gitHosted: true, tarball: https://codeload.github.com/MonadIAM/shared/tar.gz/5cb719bd74338d05a5c25961ea864605b89a7864} - version: 3.1.0 + '@monadiam/shared@https://codeload.github.com/MonadIAM/shared/tar.gz/e4bdb27ad3ff42798bf8a57ad7789baca4e04664': + resolution: {gitHosted: true, integrity: sha512-9HTJ9iu0nrjxCyqrYfDGhCFNUCWw9JxJvGLancvM8AdmF5KouTq3frobghHW4xQjPDGnIB2vpRup+/Or0dG7Kw==, tarball: https://codeload.github.com/MonadIAM/shared/tar.gz/e4bdb27ad3ff42798bf8a57ad7789baca4e04664} + version: 3.1.1 engines: {node: 24.18.0} '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': @@ -1849,6 +1849,7 @@ packages: '@opentelemetry/propagator-jaeger@2.10.0': resolution: {integrity: sha512-yw/IX8DL470dSMZJoE82ScfYGp7JWZ/G8kFJo35ZILUVTB2jFPTOaioN+8s09pH0RHsWNhweVZb+ZnjJJpCChg==} engines: {node: ^18.19.0 || >=20.6.0} + deprecated: Package no longer supported. See https://github.com/open-telemetry/opentelemetry-js/pull/7077 peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -1913,12 +1914,14 @@ packages: '@opentelemetry/sdk-trace-base@2.10.0': resolution: {integrity: sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==} engines: {node: ^18.19.0 || >=20.6.0} + deprecated: Use @opentelemetry/sdk-trace package peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' '@opentelemetry/sdk-trace-node@2.10.0': resolution: {integrity: sha512-GZK/G6oZyBLGlH1pUgeDch7D91KoHd2uotUGIkWCPi9GI5T9X0p4L7nNAMDR1BQjkRYoDqo+ddfVx9t5Uhys+Q==} engines: {node: ^18.19.0 || >=20.6.0} + deprecated: Use @opentelemetry/sdk-trace package peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -6355,7 +6358,7 @@ snapshots: uuid: 11.1.1 zod: 3.25.76 - '@monadiam/shared@https://codeload.github.com/MonadIAM/shared/tar.gz/5cb719bd74338d05a5c25961ea864605b89a7864': {} + '@monadiam/shared@https://codeload.github.com/MonadIAM/shared/tar.gz/e4bdb27ad3ff42798bf8a57ad7789baca4e04664': {} '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b5ef528..fd6453c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,7 +6,7 @@ color: true loglevel: info savePrefix: "" allowBuilds: - "@monadiam/shared@https://codeload.github.com/MonadIAM/shared/tar.gz/5cb719bd74338d05a5c25961ea864605b89a7864": true + "@monadiam/shared@https://codeload.github.com/MonadIAM/shared/tar.gz/e4bdb27ad3ff42798bf8a57ad7789baca4e04664": true "@launchql/protobufjs": true "msgpackr-extract": true "unrs-resolver": true diff --git a/src/context/application/queries/notification.contract.d.ts b/src/context/application/queries/notification.contract.d.ts index c87bf25..988b9f7 100644 --- a/src/context/application/queries/notification.contract.d.ts +++ b/src/context/application/queries/notification.contract.d.ts @@ -1,8 +1,25 @@ -import { QueryMode } from "~context/enums"; +import { QueryMode, ChannelType } from "~context/enums"; declare global { namespace Queries.Notification { - interface Contract extends ControllerContract {} + type Data = Entities.Notification & { + message: Nullable; + duplicatedTo: ChannelType[]; + }; + + interface Contract extends InternalContract, ControllerContract {} + + interface InternalContract { + mapNotification: MapNotification.Signature; + } + + namespace MapNotification { + type Props = { notification: Entities.Notification }; + + type Result = Data; + + type Signature = (props: Props) => Result; + } interface ControllerContract { countUnread: CountUnread.Signature; @@ -32,7 +49,7 @@ declare global { type Props = DefaultProps | ManageProps; - type Result = Promise; + type Result = Promise; type Signature = (props: Props) => Result; } @@ -55,7 +72,7 @@ declare global { type Props = DefaultProps | ManageProps; - type Result = Promise<[Entities.Notification[], number]>; + type Result = Promise<[Data[], number]>; type Signature = (props: Props) => Result; } diff --git a/src/context/application/queries/notification.queries.ts b/src/context/application/queries/notification.queries.ts index 8be3551..e906788 100644 --- a/src/context/application/queries/notification.queries.ts +++ b/src/context/application/queries/notification.queries.ts @@ -1,4 +1,5 @@ import { Inject, Injectable, Scope } from "@nestjs/common"; +import { LoadStrategy } from "@mikro-orm/core"; import { NOTIFICATION_REPOSITORY } from "~context/infrastructure/repositories"; import { ChannelType, MessageStatus, QueryMode } from "~context/enums"; @@ -29,7 +30,12 @@ export class NotificationQueries implements Queries.Notification.Contract { return { count }; } - public findUnique(props: Queries.Notification.FindUnique.Props): Queries.Notification.FindUnique.Result { + public async findUnique(props: Queries.Notification.FindUnique.Props): Queries.Notification.FindUnique.Result { + const options: ORM.FindOptions = { + strategy: LoadStrategy.JOINED, + populate: ["messages"], + }; + const where: ORM.FilterQuery = { id: props.notification, }; @@ -45,10 +51,17 @@ export class NotificationQueries implements Queries.Notification.Contract { }; } - return this.notificationRepository.findUniqueOrThrow({ where }); + const notification = await this.notificationRepository.findUniqueOrThrow({ where, options }); + + return this.mapNotification({ notification }); } - public findMany(props: Queries.Notification.FindMany.Props): Queries.Notification.FindMany.Result { + public async findMany(props: Queries.Notification.FindMany.Props): Queries.Notification.FindMany.Result { + const options: ORM.FindOptions = { + strategy: LoadStrategy.JOINED, + populate: ["messages"], + }; + const prefilter: ORM.ObjectQuery = {}; if (props.mode === QueryMode.DEFAULT) { @@ -62,11 +75,30 @@ export class NotificationQueries implements Queries.Notification.Contract { }; } - return this.notificationRepository.findMany({ + const [notifications, count] = await this.notificationRepository.findMany({ pagination: props.pagination, filters: props.filters, sort: props.sort, prefilter, + options, }); + + return [notifications.map((notification) => this.mapNotification({ notification })), count]; + } + + public mapNotification({ + notification, + }: Queries.Notification.MapNotification.Props): Queries.Notification.MapNotification.Result { + const result: Queries.Notification.Data = { ...notification, message: null, duplicatedTo: [] }; + + for (const message of notification.messages.getItems()) { + if (message.channelType === ChannelType.IN_APP) { + result.message = message; + } else if (message.status === MessageStatus.DELIVERED) { + result.duplicatedTo.push(message.channelType); + } + } + + return result; } } diff --git a/src/context/domain/services/message.service.ts b/src/context/domain/services/message.service.ts index d95611a..d50cb66 100644 --- a/src/context/domain/services/message.service.ts +++ b/src/context/domain/services/message.service.ts @@ -1,4 +1,5 @@ import { Inject, Injectable, Scope } from "@nestjs/common"; +import { LockMode } from "@mikro-orm/core"; import { MESSAGE_REPOSITORY } from "~context/infrastructure/repositories"; import { DISPATCH_DELAY_QUEUE } from "~context/infrastructure/queues/tokens"; @@ -49,7 +50,10 @@ export class MessageService implements Services.Message.Contract { public async markRead(props: Services.Message.MarkRead.Props): Services.Message.MarkRead.Result { const { transaction, input } = props; const message = await this.messageRepository.findUniqueOrThrow({ - options: { populate: ["notification", "notification.recipient"] }, + options: { + populate: ["notification", "notification.recipient"], + lockMode: LockMode.PESSIMISTIC_WRITE, + }, where: { id: input.message }, transaction, }); diff --git a/src/context/domain/services/message.unit.spec.ts b/src/context/domain/services/message.unit.spec.ts index 113a9c7..d2424bc 100644 --- a/src/context/domain/services/message.unit.spec.ts +++ b/src/context/domain/services/message.unit.spec.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, jest } from "@jest/globals"; +import { LockMode } from "@mikro-orm/core"; import { MessageUnitHelpers } from "~testing/unit/domain-service/message.helpers"; import { FailureReason } from "~context/enums"; @@ -109,7 +110,10 @@ describe("[DomainService] - Message", () => { // Assert expect(result).toHaveBeenCalledWith({ - options: { populate: ["notification", "notification.recipient"] }, + options: { + populate: ["notification", "notification.recipient"], + lockMode: LockMode.PESSIMISTIC_WRITE, + }, transaction: transaction.entityManager, where: { id: MESSAGE_ID }, }); diff --git a/src/context/infrastructure/mappers/notification.mapper.ts b/src/context/infrastructure/mappers/notification.mapper.ts index 7947fdb..374461f 100644 --- a/src/context/infrastructure/mappers/notification.mapper.ts +++ b/src/context/infrastructure/mappers/notification.mapper.ts @@ -1,6 +1,6 @@ import { QueryOrder } from "@mikro-orm/postgresql"; -import { NotificationCategory, PlatformService } from "~context/enums"; +import { NotificationCategory, PlatformService, ChannelType } from "~context/enums"; import { ORMAdapter } from "~infrastructure/database/utils"; export class NotificationMapper implements Repositories.Mappers.Contract< @@ -31,6 +31,18 @@ export class NotificationMapper implements Repositories.Mappers.Contract< if (filters.createdAt) { where.createdAt = ORMAdapter.applyOrdinalFilter(filters.createdAt); } + if (filters.isRead !== undefined) { + where.$and = [ + { + messages: { + $some: { + readAt: filters.isRead ? { $ne: null } : null, + channelType: ChannelType.IN_APP, + }, + }, + }, + ]; + } return where; } @@ -42,7 +54,7 @@ export class NotificationMapper implements Repositories.Mappers.Contract< ): ORM.FindOptions { return { ...basic, - orderBy: ORMAdapter.orderBy(sort, { createdAt: QueryOrder.ASC }), + orderBy: ORMAdapter.orderBy(sort, { createdAt: QueryOrder.DESC, id: QueryOrder.DESC }), ...ORMAdapter.pagination(pagination), }; } diff --git a/src/context/infrastructure/mappers/types.d.ts b/src/context/infrastructure/mappers/types.d.ts index ed9f684..646104b 100644 --- a/src/context/infrastructure/mappers/types.d.ts +++ b/src/context/infrastructure/mappers/types.d.ts @@ -110,6 +110,7 @@ declare global { realm?: LinkFilterDTO; sourceService?: StringFilterDTO; createdAt?: OrdinalFilterDTO; + isRead?: boolean; }; type Sort = { diff --git a/src/context/infrastructure/repositories/message.integration.spec.ts b/src/context/infrastructure/repositories/message.integration.spec.ts index 5b3305c..ecb2eb4 100644 --- a/src/context/infrastructure/repositories/message.integration.spec.ts +++ b/src/context/infrastructure/repositories/message.integration.spec.ts @@ -71,9 +71,10 @@ describe("[Repository] - Message", () => { notification, channel, }); + const otherNotification = await suite.fixtures().createNotification({ recipient }); await suite.fixtures().createMessage({ channelType: ChannelType.EMAIL, - notification, + notification: otherNotification, channel, }); diff --git a/src/context/infrastructure/schemas/message.schema.ts b/src/context/infrastructure/schemas/message.schema.ts index 40a414a..1144c72 100644 --- a/src/context/infrastructure/schemas/message.schema.ts +++ b/src/context/infrastructure/schemas/message.schema.ts @@ -8,6 +8,13 @@ export const MessageSchema = new EntitySchema({ tableName: "message", schema: "notification", + uniques: [ + { + name: "message_notification_channel_type_unique", + properties: ["notification", "channelType"], + }, + ], + indexes: [ { name: "message_notification_idx", diff --git a/src/context/interface/dto/notification/res/entity.dto.ts b/src/context/interface/dto/notification/res/entity.dto.ts index ea4bd86..f2af318 100644 --- a/src/context/interface/dto/notification/res/entity.dto.ts +++ b/src/context/interface/dto/notification/res/entity.dto.ts @@ -1,9 +1,11 @@ import { ApiProperty, ApiSchema } from "@nestjs/swagger"; -import { Expose } from "class-transformer"; +import { Expose, Type } from "class-transformer"; -import { NotificationCategory, PlatformService } from "~context/enums"; +import { NotificationCategory, PlatformService, ChannelType } from "~context/enums"; import { Validator } from "~common/validator"; +import { MessageDTO } from "../../message"; + @ApiSchema({ name: "Notification" }) export class NotificationDTO { @Expose() @@ -42,4 +44,17 @@ export class NotificationDTO { @Validator.IsDate() @ApiProperty({ required: true, type: Date }) declare public createdAt: Date; + + @Expose() + @Type(() => MessageDTO) + @Validator.IsOptional() + @Validator.ValidateNested() + @ApiProperty({ required: true, type: MessageDTO, nullable: true }) + declare public message: Nullable; + + @Expose() + @Validator.IsArray() + @Validator.IsEnum(ChannelType, { each: true }) + @ApiProperty({ required: true, enum: ChannelType, enumName: "ChannelType", isArray: true }) + declare public duplicatedTo: ChannelType[]; } diff --git a/src/context/interface/dto/notification/utils/filters.dto.ts b/src/context/interface/dto/notification/utils/filters.dto.ts index b33cbc1..4230550 100644 --- a/src/context/interface/dto/notification/utils/filters.dto.ts +++ b/src/context/interface/dto/notification/utils/filters.dto.ts @@ -34,6 +34,12 @@ export class DefaultFiltersDTO implements Omit { + this.addSql(`alter table "notification"."message" add constraint "message_notification_channel_type_unique" unique ("notification_id", "channel_type");`); + } + + override down(): void | Promise { + this.addSql(`alter table "notification"."message" drop constraint "message_notification_channel_type_unique";`); + } + +}