diff --git a/notify-bc-lb/src/controllers/notification.controller.ts b/notify-bc-lb/src/controllers/notification.controller.ts index a3ddfe090..eb5081ef4 100644 --- a/notify-bc-lb/src/controllers/notification.controller.ts +++ b/notify-bc-lb/src/controllers/notification.controller.ts @@ -282,6 +282,7 @@ export class NotificationController extends BaseController { await this.notificationRepository.updateById(id, notification, undefined); } + // start: ported @put('/notifications/{id}', { responses: { '204': { @@ -303,6 +304,7 @@ export class NotificationController extends BaseController { this.httpContext.bind('args').to({data: notification}); await this.dispatchNotification(notification); } + // end: ported @del('/notifications/{id}', { responses: { @@ -353,6 +355,7 @@ export class NotificationController extends BaseController { return this.sendPushNotification(notification); } + // start: ported async sendPushNotification(data: Notification) { const inboundSmtpServerDomain = this.appConfig.email.inboundSmtpServer?.domain; @@ -1022,4 +1025,5 @@ export class NotificationController extends BaseController { throw new HttpErrors[403]('invalid user'); } } + // end: ported } diff --git a/package.json b/package.json index d0601959e..2cda0782b 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@nestjs/mongoose": "^10.0.1", "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^7.1.8", + "async": "^3.2.4", "bcryptjs": "^2.4.3", "bottleneck": "^2.19.5", "class-transformer": "^0.5.1", diff --git a/src/api/bounces/bounces.module.ts b/src/api/bounces/bounces.module.ts index bf92b28f7..002403aab 100644 --- a/src/api/bounces/bounces.module.ts +++ b/src/api/bounces/bounces.module.ts @@ -17,5 +17,6 @@ import { Bounce, BounceSchema } from './entities/bounce.entity'; ], controllers: [BouncesController], providers: [BouncesService], + exports: [BouncesService], }) export class BouncesModule {} diff --git a/src/api/common/base.controller.ts b/src/api/common/base.controller.ts index 7de01ef24..010345b23 100644 --- a/src/api/common/base.controller.ts +++ b/src/api/common/base.controller.ts @@ -205,10 +205,7 @@ export class BaseController { if (this.appConfig.httpHost) { httpHost = this.appConfig.httpHost; } - let args: any; - try { - args = req.getSync('args'); - } catch (ex) {} + let args: any = req.args; if (args?.data?.httpHost) { httpHost = args.data.httpHost; } else if (req.instance?.httpHost) { diff --git a/src/api/common/base.service.ts b/src/api/common/base.service.ts index a7e244d6f..bc6672666 100644 --- a/src/api/common/base.service.ts +++ b/src/api/common/base.service.ts @@ -35,7 +35,13 @@ export class BaseService { } findAll(filter: any = {}): Promise { - const { where, fields, order, skip, limit } = filter; + let { where, fields, order, skip, limit } = filter; + if (fields instanceof Array) { + fields = fields.reduce((p, c) => { + p[c] = true; + return p; + }, {}); + } const castedWhere = this.model.find(where).cast(); return this.model .aggregate( @@ -107,8 +113,8 @@ export class BaseService { .exec(); } - findById(id: string): Promise { - return this.model.findById(id).exec(); + async findById(id: string): Promise { + return (await this.model.findById(id).exec()).toJSON(); } updateById(id: string, updateDto, req?: (Request & { user?: any }) | null) { diff --git a/src/api/notifications/entities/notification.entity.ts b/src/api/notifications/entities/notification.entity.ts index ec8a74aa5..cc6f88df0 100644 --- a/src/api/notifications/entities/notification.entity.ts +++ b/src/api/notifications/entities/notification.entity.ts @@ -1,5 +1,5 @@ -import { Schema, SchemaFactory } from '@nestjs/mongoose'; -import { HydratedDocument } from 'mongoose'; +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import mongoose, { AnyObject, HydratedDocument } from 'mongoose'; import { BaseEntity, BaseSchemaOptions } from 'src/api/common/base.entity'; export type NotificationDocument = HydratedDocument; @@ -8,7 +8,63 @@ export type NotificationDocument = HydratedDocument; collection: 'notification', ...BaseSchemaOptions, }) -export class Notification extends BaseEntity {} +export class Notification extends BaseEntity { + @Prop({ required: true }) + serviceName: string; + + @Prop({ + default: 'new', + }) + state: string; + + @Prop() + userChannelId?: string; + + @Prop({ + type: mongoose.Schema.Types.Mixed, + required: true, + default: {}, + }) + message: AnyObject; + + @Prop({ + required: true, + default: 'inApp', + }) + channel: string; + + @Prop({ + default: false, + }) + isBroadcast?: boolean; + + @Prop({ + default: false, + }) + skipSubscriptionConfirmationCheck?: boolean; + + @Prop() + validTill?: Date; + + @Prop() + invalidBefore?: Date; + + @Prop({ + type: mongoose.Schema.Types.Mixed, + }) + data?: AnyObject; + + @Prop({ + type: mongoose.Schema.Types.Mixed, + }) + asyncBroadcastPushNotification?: any; + + @Prop() + broadcastPushNotificationSubscriptionFilter?: string; + + // Indexer property to allow additional data + [prop: string]: any; +} export const NotificationSchema = SchemaFactory.createForClass( Notification, diff --git a/src/api/notifications/notifications.controller.ts b/src/api/notifications/notifications.controller.ts index 7d7d5225d..dc634d757 100644 --- a/src/api/notifications/notifications.controller.ts +++ b/src/api/notifications/notifications.controller.ts @@ -1,17 +1,62 @@ -import { Controller, Get, Req } from '@nestjs/common'; -import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; -import { FilterQuery } from 'mongoose'; +import { + Body, + Controller, + Get, + HttpCode, + HttpException, + HttpStatus, + Inject, + Param, + Put, + Scope, +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { ApiNoContentResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { queue } from 'async'; +import { Request } from 'express'; +import jmespath from 'jmespath'; +import { pullAll } from 'lodash'; +import { AnyObject, FilterQuery } from 'mongoose'; import { Role } from 'src/auth/constants'; +import { UserProfile } from 'src/auth/dto/user-profile.dto'; import { Roles } from 'src/auth/roles.decorator'; +import { AppConfigService } from 'src/config/app-config.service'; +import { promisify } from 'util'; +import { BouncesService } from '../bounces/bounces.service'; +import { BaseController } from '../common/base.controller'; import { CountDto } from '../common/dto/count.dto'; import { ApiWhereJsonQuery, JsonQuery } from '../common/json-query.decorator'; +import { ConfigurationsService } from '../configurations/configurations.service'; +import { Subscription } from '../subscriptions/entities/subscription.entity'; +import { SubscriptionsService } from '../subscriptions/subscriptions.service'; +import { CreateNotificationDto } from './dto/create-notification.dto'; +import { Notification } from './entities/notification.entity'; import { NotificationsService } from './notifications.service'; +const wait = promisify(setTimeout); +enum NotificationDispatchStatusField { + failed, + successful, + skipped, +} -@Controller('notifications') +@Controller({ + path: 'notifications', + scope: Scope.REQUEST, +}) @ApiTags('notification') @Roles(Role.Admin, Role.SuperAdmin, Role.AuthenticatedUser) -export class NotificationsController { - constructor(private readonly notificationsService: NotificationsService) {} +export class NotificationsController extends BaseController { + constructor( + private readonly notificationsService: NotificationsService, + private readonly subscriptionsService: SubscriptionsService, + readonly appConfigService: AppConfigService, + readonly configurationsService: ConfigurationsService, + private readonly bouncesService: BouncesService, + @Inject(REQUEST) private readonly req: Request & { user: UserProfile }, + ) { + super(appConfigService, configurationsService); + } + chunkRequestAborted = false; @Get('count') @ApiOkResponse({ @@ -20,10 +65,613 @@ export class NotificationsController { }) @ApiWhereJsonQuery() async count( - @Req() req, @JsonQuery('where') where?: FilterQuery, ): Promise { return this.notificationsService.count(where); } + + @Put(':id') + @ApiNoContentResponse({ + description: 'Notification PUT success', + }) + @HttpCode(204) + async replaceById( + @Param('id') id: string, + @Body() notification: CreateNotificationDto, + ): Promise { + await this.preCreationValidation(notification); + await this.notificationsService.replaceById(id, notification, this.req); + notification = await this.notificationsService.findById(id); + this.req['args'] = { data: notification }; + await this.dispatchNotification(notification as Notification); + } + + async sendPushNotification(data: Notification) { + const inboundSmtpServerDomain = + this.appConfig.email.inboundSmtpServer?.domain; + const handleBounce = this.appConfig.email?.bounce?.enabled; + const handleListUnsubscribeByEmail = + this.appConfig.email?.listUnsubscribeByEmail?.enabled; + + const updateBounces = async ( + userChannelIds: string[] | string, + dataNotification: Notification, + ) => { + if (!handleBounce) { + return; + } + let userChannelIdQry: any = userChannelIds; + if (userChannelIds instanceof Array) { + userChannelIdQry = { + $in: userChannelIds, + }; + } + await this.bouncesService.updateAll( + { + latestNotificationStarted: dataNotification.updated, + latestNotificationEnded: new Date(), + }, + { + state: 'active', + channel: dataNotification.channel, + userChannelId: userChannelIdQry, + $or: [ + { + latestNotificationStarted: null, + }, + { + latestNotificationStarted: { + $lt: dataNotification.updated, + }, + }, + ], + }, + this.req, + ); + }; + + switch (data.isBroadcast) { + case false: { + let sub: Partial = + this.req['NotifyBC.subscription'] ?? {}; + const textBody = + data.message.textBody && + this.mailMerge(data.message.textBody, sub, data, this.req); + switch (data.channel) { + case 'sms': + await this.sendSMS(data.userChannelId as string, textBody, sub); + return; + default: { + const htmlBody = + data.message.htmlBody && + this.mailMerge(data.message.htmlBody, sub, data, this.req); + const subject = + data.message.subject && + this.mailMerge(data.message.subject, sub, data, this.req); + const unsubscriptUrl = this.mailMerge( + '{unsubscription_url}', + sub, + data, + this.req, + ); + let listUnsub = unsubscriptUrl; + if (handleListUnsubscribeByEmail && inboundSmtpServerDomain) { + const unsubEmail = + this.mailMerge( + 'un-{subscription_id}-{unsubscription_code}@', + sub, + data, + this.req, + ) + inboundSmtpServerDomain; + listUnsub = [[unsubEmail, unsubscriptUrl]]; + } + const mailOptions: AnyObject = { + from: data.message.from, + to: data.userChannelId, + subject: subject, + text: textBody, + html: htmlBody, + list: { + id: data.httpHost + '/' + encodeURIComponent(data.serviceName), + unsubscribe: listUnsub, + }, + }; + if (handleBounce && inboundSmtpServerDomain) { + const bounceEmail = this.mailMerge( + `bn-{subscription_id}-{unsubscription_code}@${inboundSmtpServerDomain}`, + sub, + data, + this.req, + ); + mailOptions.envelope = { + from: bounceEmail, + to: data.userChannelId, + }; + } + await this.sendEmail(mailOptions); + await updateBounces(data.userChannelId as string, data); + return; + } + } + } + case true: { + const broadcastSubscriberChunkSize = + this.appConfig.notification?.broadcastSubscriberChunkSize; + const broadcastSubRequestBatchSize = + this.appConfig.notification?.broadcastSubRequestBatchSize; + const guaranteedBroadcastPushDispatchProcessing = + this.appConfig.notification + ?.guaranteedBroadcastPushDispatchProcessing; + const logSkippedBroadcastPushDispatches = + this.appConfig.notification?.logSkippedBroadcastPushDispatches; + let startIdx: undefined | number = this.req['NotifyBC.startIdx']; + const updateBroadcastPushNotificationStatus = async ( + field: NotificationDispatchStatusField, + payload: any, + ) => { + let success = false; + while (!success) { + try { + const val = + payload instanceof Array ? { $each: payload } : payload; + await this.notificationsService.updateById( + data.id, + { + $push: { + ['dispatch.' + NotificationDispatchStatusField[field]]: val, + }, + }, + this.req, + ); + success = true; + return; + } catch (ex) {} + await wait(1000); + } + }; + const broadcastToSubscriberChunk = async () => { + const subChunk = (data.dispatch.candidates as string[]).slice( + startIdx, + startIdx + broadcastSubscriberChunkSize, + ); + pullAll( + pullAll( + pullAll( + subChunk, + (data.dispatch?.failed ?? []).map((e: any) => e.subscriptionId), + ), + data.dispatch?.successful ?? [], + ), + data.dispatch?.skipped ?? [], + ); + const subscribers = await this.subscriptionsService.findAll( + this.req, + { + where: { + id: { $in: subChunk }, + }, + }, + ); + const jmespathSearchOpts: AnyObject = {}; + const ft = + this.appConfig.notification?.broadcastCustomFilterFunctions; + if (ft) { + jmespathSearchOpts.functionTable = ft; + } + const notificationMsgCB = async (err: any, e: Subscription) => { + if (err) { + return updateBroadcastPushNotificationStatus( + NotificationDispatchStatusField.failed, + { + subscriptionId: e.id, + userChannelId: e.userChannelId, + error: err, + }, + ); + } else if ( + guaranteedBroadcastPushDispatchProcessing || + handleBounce + ) { + return updateBroadcastPushNotificationStatus( + NotificationDispatchStatusField.successful, + e.id, + ); + } + }; + await Promise.all( + subscribers.map(async (e) => { + if (e.broadcastPushNotificationFilter && data.data) { + let match; + try { + match = await jmespath.search( + [data.data], + '[?' + e.broadcastPushNotificationFilter + ']', + jmespathSearchOpts, + ); + } catch (ex) {} + if (!match || match.length === 0) { + if ( + guaranteedBroadcastPushDispatchProcessing && + logSkippedBroadcastPushDispatches + ) + await updateBroadcastPushNotificationStatus( + NotificationDispatchStatusField.skipped, + e.id, + ); + return; + } + } + if (e.data && data.broadcastPushNotificationSubscriptionFilter) { + let match; + try { + match = await jmespath.search( + [e.data], + '[?' + + data.broadcastPushNotificationSubscriptionFilter + + ']', + jmespathSearchOpts, + ); + } catch (ex) {} + if (!match || match.length === 0) { + if ( + guaranteedBroadcastPushDispatchProcessing && + logSkippedBroadcastPushDispatches + ) + await updateBroadcastPushNotificationStatus( + NotificationDispatchStatusField.skipped, + e.id, + ); + return; + } + } + const textBody = + data.message.textBody && + this.mailMerge(data.message.textBody, e, data, this.req); + switch (e.channel) { + case 'sms': + try { + if (this.chunkRequestAborted) + throw new HttpException( + undefined, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + await this.sendSMS(e.userChannelId, textBody, e); + return await notificationMsgCB(null, e); + } catch (ex) { + return await notificationMsgCB(ex, e); + } + break; + default: { + const subject = + data.message.subject && + this.mailMerge(data.message.subject, e, data, this.req); + const htmlBody = + data.message.htmlBody && + this.mailMerge(data.message.htmlBody, e, data, this.req); + const unsubscriptUrl = this.mailMerge( + '{unsubscription_url}', + e, + data, + this.req, + ); + let listUnsub = unsubscriptUrl; + if (handleListUnsubscribeByEmail && inboundSmtpServerDomain) { + const unsubEmail = + this.mailMerge( + 'un-{subscription_id}-{unsubscription_code}@', + e, + data, + this.req, + ) + inboundSmtpServerDomain; + listUnsub = [[unsubEmail, unsubscriptUrl]]; + } + const mailOptions: AnyObject = { + from: data.message.from, + to: e.userChannelId, + subject: subject, + text: textBody, + html: htmlBody, + list: { + id: + data.httpHost + + '/' + + encodeURIComponent(data.serviceName), + unsubscribe: listUnsub, + }, + }; + if (handleBounce && inboundSmtpServerDomain) { + const bounceEmail = this.mailMerge( + `bn-{subscription_id}-{unsubscription_code}@${inboundSmtpServerDomain}`, + e, + data, + this.req, + ); + mailOptions.envelope = { + from: bounceEmail, + to: e.userChannelId, + }; + } + try { + if (this.chunkRequestAborted) + throw new HttpException( + undefined, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + await this.sendEmail(mailOptions); + return await notificationMsgCB(null, e); + } catch (ex) { + return await notificationMsgCB(ex, e); + } + } + } + }), + ); + }; + if (typeof startIdx !== 'number') { + // main request + const postBroadcastProcessing = async () => { + data = await this.notificationsService.findById(data.id); + const res = await this.subscriptionsService.findAll(this.req, { + fields: { + userChannelId: true, + }, + where: { + id: { + $in: data.dispatch?.successful, + }, + }, + }); + const userChannelIds = res.map((e) => e.userChannelId); + const errUserChannelIds = (data.dispatch?.failed || []).map( + (e: { userChannelId: any }) => e.userChannelId, + ); + pullAll(userChannelIds, errUserChannelIds); + await updateBounces(userChannelIds, data); + + if (!data.asyncBroadcastPushNotification) { + return; + } else { + if (data.state !== 'error') { + data.state = 'sent'; + } + await this.notificationsService.updateById( + data.id, + { state: data.state }, + this.req, + ); + if (typeof data.asyncBroadcastPushNotification === 'string') { + const options = { + method: 'POST', + body: JSON.stringify(data), + headers: { + 'Content-Type': 'application/json', + }, + }; + try { + await fetch(data.asyncBroadcastPushNotification, options); + } catch (ex) {} + } + } + }; + + const subCandidates = await this.subscriptionsService.findAll( + this.req, + { + where: { + serviceName: data.serviceName, + state: 'confirmed', + channel: data.channel, + }, + fields: ['id'], + }, + ); + data.dispatch = data.dispatch ?? {}; + data.dispatch.candidates = + data.dispatch.candidates ?? subCandidates.map((e) => e.id); + await this.notificationsService.updateById( + data.id, + { + state: 'sending', + dispatch: data.dispatch, + }, + this.req, + ); + const hbTimeout = setInterval(() => { + this.notificationsService.updateById( + data.id, + { + updated: new Date(), + }, + this.req, + ); + }, 60000); + + const count = subCandidates.length; + + if (count <= broadcastSubscriberChunkSize) { + startIdx = 0; + await broadcastToSubscriberChunk(); + await postBroadcastProcessing(); + } else { + // call broadcastToSubscriberChunk, coordinate output + const chunks = Math.ceil(count / broadcastSubscriberChunkSize); + let httpHost = this.appConfig.internalHttpHost; + const restApiRoot = this.appConfig.restApiRoot ?? ''; + if (!httpHost) { + httpHost = + data.httpHost || + this.req.protocol + '://' + this.req.get('host'); + } + + const q = queue(async (task: { startIdx: string }) => { + const uri = + httpHost + + restApiRoot + + '/notifications/' + + data.id + + '/broadcastToChunkSubscribers?start=' + + task.startIdx; + const response = await fetch(uri); + if (response.status < 300) { + try { + return await response.json(); + } catch (ex) { + return response.body; + } + } + throw new HttpException(undefined, response.status); + }, broadcastSubRequestBatchSize); + // re-submit task on error if + // guaranteedBroadcastPushDispatchProcessing. + // See issue #39 + let failedChunks: any[] = []; + q.error((_err: any, task: any) => { + if (guaranteedBroadcastPushDispatchProcessing) { + q.push(task); + } else { + data.state = 'error'; + // mark all chunk subs as failed + const subChunk = (data.dispatch.candidates as string[]).slice( + task.startIdx, + task.startIdx + broadcastSubscriberChunkSize, + ); + failedChunks = failedChunks.concat( + subChunk.map((e) => { + return { subscriptionId: e }; + }), + ); + } + }); + let i = 0; + while (i < chunks) { + q.push({ + startIdx: i++ * broadcastSubscriberChunkSize, + }); + } + await q.drain(); + if (failedChunks.length > 0) { + await updateBroadcastPushNotificationStatus( + NotificationDispatchStatusField.failed, + failedChunks, + ); + } + await postBroadcastProcessing(); + } + clearTimeout(hbTimeout); + } else { + return broadcastToSubscriberChunk(); + } + break; + } + } + } + + public async dispatchNotification(res: Notification): Promise { + // send non-inApp notifications immediately + switch (res.channel) { + case 'email': + case 'sms': + if (res.invalidBefore && res.invalidBefore > new Date()) { + return res; + } + if (!res.httpHost) { + res.httpHost = this.appConfig.httpHost; + if (!res.httpHost && this.req) { + res.httpHost = this.req.protocol + '://' + this.req.get('host'); + } + } + try { + if (res.isBroadcast && res.asyncBroadcastPushNotification) { + // async + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.sendPushNotification(res); + return res; + } else { + await this.sendPushNotification(res); + res.state = 'sent'; + } + } catch (errSend: any) { + res.state = 'error'; + } + await this.notificationsService.updateById( + res.id, + { state: res.state }, + this.req, + ); + break; + default: + break; + } + return this.notificationsService.findById(res.id); + } + + public async preCreationValidation(data: CreateNotificationDto) { + if (![Role.Admin, Role.SuperAdmin].includes(this.req.user.role)) { + throw new HttpException(undefined, HttpStatus.FORBIDDEN); + } + if ( + !data.isBroadcast && + data.skipSubscriptionConfirmationCheck && + !data.userChannelId + ) { + throw new HttpException('invalid user', HttpStatus.FORBIDDEN); + } + let filter = data.broadcastPushNotificationSubscriptionFilter; + if (data.isBroadcast && data.channel !== 'inApp' && filter) { + filter = '[?' + filter + ']'; + try { + jmespath.compile(filter); + } catch (ex) { + throw new HttpException( + 'invalid broadcastPushNotificationFilter', + HttpStatus.BAD_REQUEST, + ); + } + } + if ( + data.channel === 'inApp' || + data.skipSubscriptionConfirmationCheck || + data.isBroadcast + ) { + return; + } + if (!data.userChannelId && !data.userId) { + throw new HttpException('invalid user', HttpStatus.FORBIDDEN); + } + // validate userChannelId/userId of a unicast push notification against subscription data + const whereClause: FilterQuery = { + serviceName: data.serviceName, + state: 'confirmed', + channel: data.channel, + }; + if (data.userChannelId) { + // email address check should be case insensitive + const escapedUserChannelId = data.userChannelId.replace( + /[-[\]{}()*+?.,\\^$|#\s]/g, + '\\$&', + ); + const escapedUserChannelIdRegExp = new RegExp(escapedUserChannelId, 'i'); + whereClause.userChannelId = { + regexp: escapedUserChannelIdRegExp, + }; + } + if (data.userId) { + whereClause.userId = data.userId; + } + + try { + const subscription = await this.subscriptionsService.findOne(this.req, { + where: whereClause, + }); + if (!subscription) { + throw new HttpException('invalid user', HttpStatus.FORBIDDEN); + } + // in case request supplies userId instead of userChannelId + data.userChannelId = subscription?.userChannelId; + this.req['NotifyBC.subscription'] = subscription; + } catch (ex) { + throw new HttpException('invalid user', HttpStatus.FORBIDDEN); + } + } } diff --git a/src/api/notifications/notifications.module.ts b/src/api/notifications/notifications.module.ts index fd7280c03..ea57c28ee 100644 --- a/src/api/notifications/notifications.module.ts +++ b/src/api/notifications/notifications.module.ts @@ -1,5 +1,8 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; +import { BouncesModule } from '../bounces/bounces.module'; +import { ConfigurationsModule } from '../configurations/configurations.module'; +import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; import { Notification, NotificationSchema, @@ -9,6 +12,9 @@ import { NotificationsService } from './notifications.service'; @Module({ imports: [ + SubscriptionsModule, + ConfigurationsModule, + BouncesModule, MongooseModule.forFeature([ { name: Notification.name, schema: NotificationSchema }, ]), diff --git a/src/api/subscriptions/subscriptions.module.ts b/src/api/subscriptions/subscriptions.module.ts index b6e293327..e55bf1ca2 100644 --- a/src/api/subscriptions/subscriptions.module.ts +++ b/src/api/subscriptions/subscriptions.module.ts @@ -17,5 +17,6 @@ import { SubscriptionsService } from './subscriptions.service'; ], controllers: [SubscriptionsController], providers: [SubscriptionsService], + exports: [SubscriptionsService], }) export class SubscriptionsModule {} diff --git a/yarn.lock b/yarn.lock index d03f9a103..0ff583fb3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2264,6 +2264,11 @@ async-mutex@^0.3.2: dependencies: tslib "^2.3.1" +async@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c" + integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ== + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"