diff --git a/.gitignore b/.gitignore index f483b3821..e7dd40114 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ speed-measure-plugin.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json +.history/* # misc /.sass-cache diff --git a/PLAYBOOK-NEST.md b/PLAYBOOK-NEST.md index c1c44c3c9..05b9a7070 100644 --- a/PLAYBOOK-NEST.md +++ b/PLAYBOOK-NEST.md @@ -66,54 +66,47 @@ npm i -D @types/helmet ```bash # scaffold core module -nest g module core --dry-run -nest g guard auth core --dry-run -nest g exception auth --dry-run +nest g module app/core --dry-run +nest g guard auth app/core --dry-run -# scaffold shared module -nest g module shared --dry-run -nest g gateway eventbus shared --dry-run # /src/shared/eventbus/eventbus.gateway.ts to shared/eventbus.gateway.ts -# scaffold project module -nest g module project --dry-run -nest g controller project --dry-run -nest g service project project --dry-run -nest g class project --dry-run +# scaffold shared module +nest g module app/shared --dry-run +nest g gateway eventbus app/shared --dry-run # scaffold user module -nest g module user --dry-run -nest g controller profile user --dry-run -nest g service profile user --dry-run -nest g class profile user --dry-run -nest g controller email user --dry-run +nest g module app/user --dry-run +nest g controller profile app/user --dry-run +nest g service profile app/user --dry-run +nest g class profile/profile.entity app/user --no-spec --dry-run +nest g controller email app/user --dry-run # scaffold email module -nest g module email --dry-run -nest g service email email --dry-run +nest g module app/email --dry-run +nest g service email app/email --flat --dry-run # scaffold auth module -nest g module auth --dry-run -nest g service auth auth --dry-run -nest g controller auth --dry-run -nest g class user auth --dry-run # move ../ and rename as user.entity.ts - -# scaffold chat module -nest g module chat --dry-run -nest g service chat chat --dry-run -nest g controller chat --dry-run -nest g gateway chat --dry-run +nest g module app/auth --dry-run +nest g controller auth app/auth --flat --dry-run +nest g service auth app/auth --flat --dry-run +nest g class user.entity app/auth --no-spec --dry-run +nest g class auth.exception app/auth --no-spec --dry-run # scaffold chat module -nest g module notifications --dry-run -nest g controller notifications --dry-run -nest g service notifications notifications --dry-run -nest g class notification notifications --dry-run - -# scaffold push module -nest g module push --dry-run -nest g controller push --dry-run -nest g service push --dry-run -nest g class subscription push --no-spec --dry-run # rename as subscription.entity.ts +nest g module app/chat --dry-run +nest g controller chat app/chat --flat --dry-run +nest g service chat app/chat --flat --dry-run +nest g gateway chat app/chat --flat --dry-run + +# scaffold notifications module +nest g module app/notifications --dry-run +nest g controller notification app/notifications --dry-run +nest g service notification app/notifications --dry-run +nest g service notification/push app/notifications --flat --no-spec --dry-run +nest g class notification/notification.entity app/notifications --no-spec --dry-run +nest g controller subscription app/notifications --dry-run +nest g service subscription app/notifications --dry-run +nest g class subscription/subscription.entity app/notifications --no-spec --dry-run ``` ### Ref diff --git a/apps/api/README.md b/apps/api/README.md index 2814c1d6a..f3e683037 100755 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -84,16 +84,12 @@ npm run api:build # check of nest installed nest info -# scaffold project module -nest generate module project --dry-run -nest generate controller project --dry-run -nest generate service project project --dry-run -nest generate class project --dry-run - -# scaffold core module -nest g module core --dry-run -nest g guard auth core --dry-run -nest g exception auth --dry-run +# scaffold auth module +nest g module app/auth --dry-run +nest g controller auth app/auth --flat --dry-run +nest g service auth app/auth --flat --dry-run +nest g class user.entity app/auth --no-spec --dry-run +nest g class auth.exception app/auth --no-spec --dry-run ``` ### Test diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index bf8ef63ef..1abf11258 100755 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -7,7 +7,6 @@ import { UserModule } from './user'; // import { ChatModule } from './chat'; import { AppController } from './app.controller'; import { NotificationsModule } from './notifications'; -import { PushModule } from './push'; import { ExternalModule } from './external'; @Module({ @@ -18,9 +17,8 @@ import { ExternalModule } from './external'; children: [ { path: '/auth', module: AuthModule }, { path: '/user', module: UserModule }, - { path: '/push', module: PushModule }, // { path: '/account', module: AccountModule }, - { path: '/notifications', module: NotificationsModule }, + { path: '/', module: NotificationsModule }, ], }, { path: '/external', module: ExternalModule }, @@ -32,7 +30,6 @@ import { ExternalModule } from './external'; // ChatModule, ExternalModule, NotificationsModule, - PushModule, ], controllers: [AppController], }) diff --git a/apps/api/src/app/core/core.module.ts b/apps/api/src/app/core/core.module.ts index 725f612e8..f1b6dc66a 100644 --- a/apps/api/src/app/core/core.module.ts +++ b/apps/api/src/app/core/core.module.ts @@ -7,10 +7,10 @@ import { LoggingInterceptor, TransformInterceptor } from './interceptors'; import { RequestContextMiddleware } from './context'; import { ConfigService } from '../config'; import { ConnectionOptions } from 'typeorm'; -import { Notification } from '../notifications/notification.entity'; -import { User } from '../auth/user.entity'; import { environment as env } from '@env-api/environment'; -import { Subscription } from '../push/subscription.entity'; +import { User } from '../auth/user.entity'; +import { Notification } from '../notifications/notification/notification.entity'; +import { Subscription } from '../notifications/subscription/subscription.entity'; @Module({ imports: [ @@ -21,7 +21,7 @@ import { Subscription } from '../push/subscription.entity'; useFactory: async (config: ConfigService) => ({ ...env.database, - entities: [Notification, User, Subscription], + entities: [User, Notification, Subscription], } as ConnectionOptions), inject: [ConfigService], }), diff --git a/apps/api/src/app/notifications/dto/create-notification.dto.ts b/apps/api/src/app/notifications/dto/create-notification.dto.ts deleted file mode 100644 index 22d0d6b3d..000000000 --- a/apps/api/src/app/notifications/dto/create-notification.dto.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ApiModelProperty } from '@nestjs/swagger'; -import { IsAscii, IsBoolean, IsEnum, IsIn, IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator'; -import { NotificationColor, NotificationIcon } from '../notification.entity'; - -export class CreateNotificationDto { - @ApiModelProperty({ type: String, enum: NotificationIcon, default: NotificationIcon.notifications }) - @IsString() - @IsNotEmpty() - @IsEnum(NotificationIcon) - icon: NotificationIcon; - - @ApiModelProperty({ type: String, minLength: 10, maxLength: 100 }) - @IsString() - @IsNotEmpty() - message: string; - - @ApiModelProperty({ type: Boolean, default: false }) - @IsBoolean() - @IsNotEmpty() - read: boolean = false; - - @ApiModelProperty({ type: String, enum: NotificationColor, default: NotificationColor.PRIMARY }) - @IsString() - @IsNotEmpty() - @IsIn(['warn', 'accent', 'primary']) - color?: NotificationColor; - - @ApiModelProperty({ type: String, minLength: 8, maxLength: 20, default: 'all' }) - @IsAscii() - @IsNotEmpty() - @MinLength(3) - @MaxLength(20) - userId: string; - - @ApiModelProperty({ type: Boolean, default: false }) - @IsBoolean() - @IsNotEmpty() - public native = false; -} diff --git a/apps/api/src/app/notifications/interfaces/notification.actions.ts b/apps/api/src/app/notifications/interfaces/notification.actions.ts index 023aa8fd5..4f1baba08 100644 --- a/apps/api/src/app/notifications/interfaces/notification.actions.ts +++ b/apps/api/src/app/notifications/interfaces/notification.actions.ts @@ -1,10 +1,15 @@ // Actions -export class AddNotification { - static readonly type = '[Notifications] Add'; - constructor(public readonly payload: any) {} -} export class DeleteNotification { static readonly type = '[Notifications] Delete'; constructor(public readonly payload: any) {} } + +export class MarkAsRead { + static readonly type = '[Notifications] MarkAsRead'; + constructor(public readonly payload: any) {} +} + +export class MarkAllAsRead { + static readonly type = '[Notifications] MarkAllAsRead'; +} diff --git a/apps/api/src/app/notifications/notification.entity.ts b/apps/api/src/app/notifications/notification.entity.ts deleted file mode 100644 index 577dc794f..000000000 --- a/apps/api/src/app/notifications/notification.entity.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Column, CreateDateColumn, Entity, Index } from 'typeorm'; -import { Base } from '../core/entities/base.entity'; -import { ApiModelProperty } from '@nestjs/swagger'; -import { IsAscii, IsBoolean, IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator'; - -export enum NotificationColor { - WARN = 'warn', - ACCENT = 'accent', - PRIMARY = 'primary', -} - -export enum NotificationIcon { - notifications = 'notifications', - notifications_active = 'notifications_active', - shopping_basket = 'shopping_basket', - eject = 'eject', - cached = 'cached', - code = 'code', -} - -@Entity('notification') -export class Notification extends Base { - @ApiModelProperty({ type: String, enum: NotificationIcon, default: NotificationIcon.notifications }) - @IsString() - @IsNotEmpty() - @Column() - icon: NotificationIcon; - - @ApiModelProperty({ type: String, minLength: 10, maxLength: 100 }) - @IsString() - @IsNotEmpty() - @Column() - message: string; - - @ApiModelProperty({ type: 'string', format: 'date-time', example: '2018-11-21T06:20:32.232Z' }) - @CreateDateColumn() - createdAt?: Date; - - @ApiModelProperty({ type: Boolean, default: false }) - @IsBoolean() - @IsNotEmpty() - @Index() - @Column() - read: boolean; - - @ApiModelProperty({ type: String, enum: NotificationColor, default: NotificationColor.PRIMARY }) - @IsString() - @IsNotEmpty() - @Column({ enum: ['warn', 'accent', 'primary'] }) - color?: NotificationColor; - - @ApiModelProperty({ type: String, minLength: 8, maxLength: 20 }) - @IsAscii() - @IsNotEmpty() - @MinLength(8) - @MaxLength(20) - @Index() - @Column() - userId: string; - - @ApiModelProperty({ type: Boolean, default: false }) - @IsBoolean() - @IsNotEmpty() - @Index() - @Column({ default: false }) - native: boolean; -} diff --git a/apps/api/src/app/notifications/notification/dto/create-notification.dto.ts b/apps/api/src/app/notifications/notification/dto/create-notification.dto.ts new file mode 100644 index 000000000..1f1f35f86 --- /dev/null +++ b/apps/api/src/app/notifications/notification/dto/create-notification.dto.ts @@ -0,0 +1,42 @@ +import { ApiModelProperty, ApiModelPropertyOptional } from '@nestjs/swagger'; +import { IsAscii, IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; +import { NotificationColor, NotificationIcon, TargetType } from '../notification.entity'; + +export class CreateNotificationDto { + @ApiModelProperty({ type: String, minLength: 10, maxLength: 100 }) + @IsNotEmpty() + @IsString() + readonly title: string; + + @ApiModelProperty({ type: String, minLength: 10, maxLength: 100 }) + @IsNotEmpty() + @IsString() + readonly body: string; + + @ApiModelProperty({ type: String, minLength: 3, maxLength: 50 }) + @IsNotEmpty() + @IsAscii() + @MinLength(3) + @MaxLength(50) + readonly target: string; + + @ApiModelProperty({ type: String, enum: TargetType }) + @IsNotEmpty() + @IsEnum(TargetType) + readonly targetType: TargetType; + + @ApiModelPropertyOptional({ type: String, enum: NotificationIcon, default: NotificationIcon.notifications }) + @IsOptional() + @IsEnum(NotificationIcon) + readonly icon?: NotificationIcon; + + @ApiModelPropertyOptional({ type: String, enum: NotificationColor, default: NotificationColor.PRIMARY }) + @IsOptional() + @IsEnum(NotificationColor) + readonly color?: NotificationColor; + + @ApiModelPropertyOptional({ type: Boolean, default: false }) + @IsOptional() + @IsBoolean() + readonly native?: boolean; +} diff --git a/apps/api/src/app/notifications/notification/dto/send-notification.dto.ts b/apps/api/src/app/notifications/notification/dto/send-notification.dto.ts new file mode 100644 index 000000000..0c40d774b --- /dev/null +++ b/apps/api/src/app/notifications/notification/dto/send-notification.dto.ts @@ -0,0 +1,14 @@ +import { ApiModelProperty, ApiModelPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class SendNotificationDto { + @ApiModelProperty({ type: String }) + @IsNotEmpty() + @IsString() + id: string; + + @ApiModelPropertyOptional({ type: String }) + @IsOptional() + @IsString() + target?: string; +} diff --git a/apps/api/src/app/notifications/notification/notification.controller.spec.ts b/apps/api/src/app/notifications/notification/notification.controller.spec.ts new file mode 100644 index 000000000..db4ce41d4 --- /dev/null +++ b/apps/api/src/app/notifications/notification/notification.controller.spec.ts @@ -0,0 +1,22 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotificationController } from './notification.controller'; +import { NotificationService } from './notification.service'; + +describe('Notification Controller', () => { + let module: TestingModule; + beforeAll(async () => { + module = await Test.createTestingModule({ + controllers: [NotificationController], + providers: [ + { + provide: NotificationService, + useValue: {}, // TODO: Mock + }, + ], + }).compile(); + }); + it('should be defined', () => { + const controller: NotificationController = module.get(NotificationController); + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/api/src/app/notifications/notification/notification.controller.ts b/apps/api/src/app/notifications/notification/notification.controller.ts new file mode 100644 index 000000000..7c26526db --- /dev/null +++ b/apps/api/src/app/notifications/notification/notification.controller.ts @@ -0,0 +1,108 @@ +import { Body, Controller, Delete, Get, HttpStatus, Param, Post, Put } from '@nestjs/common'; +import { CrudController } from '../../core'; +import { ApiOAuth2Auth, ApiOperation, ApiResponse, ApiUseTags, ApiExcludeEndpoint } from '@nestjs/swagger'; +import { Notification, TargetType } from './notification.entity'; +import { CreateNotificationDto } from './dto/create-notification.dto'; +import { NotificationService } from './notification.service'; +import { CurrentUser, Roles, RolesEnum, User } from '../../auth'; +import { DeepPartial } from 'typeorm'; +import { SendNotificationDto } from './dto/send-notification.dto'; + +@ApiOAuth2Auth(['read']) +@ApiUseTags('Sumo', 'Notifications') +@Controller('notifications') +export class NotificationController extends CrudController { + constructor(private readonly notificationService: NotificationService) { + super(notificationService); + } + + @ApiOperation({ title: 'find all Notifications. Admins only' }) + @ApiResponse({ status: HttpStatus.OK, description: 'All Notifications', /* type: Notification, */ isArray: true }) + @ApiUseTags('admin') + @Roles(RolesEnum.ADMIN) + @Get() + async findAll(): Promise<[Notification[], number]> { + return this.notificationService.getAll(); + } + + @ApiOperation({ title: 'find all user and global Notifications' }) + @ApiResponse({ status: HttpStatus.OK, description: 'All user Notifications', /* type: Notification, */ isArray: true }) + @Get('user') + async getUserNotifications(@CurrentUser() user): Promise<[Notification[], number]> { + return this.notificationService.getUserNotifications(user); + } + + @ApiOperation({ title: 'Find by id. Admins only' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Found one record', type: Notification }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Record not found' }) + @ApiUseTags('admin') + @Roles(RolesEnum.ADMIN) + @Get(':id') + async findById(@Param('id') id: string): Promise { + return super.findById(id); + } + + @ApiOperation({ title: 'Create new record. Admins only' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'The record has been successfully created.', + type: Notification, + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid input, The response body may contain clues as to what went wrong', + }) + @ApiUseTags('admin') + @Roles(RolesEnum.ADMIN) + @Post() + async create(@Body() entity: CreateNotificationDto): Promise { + return super.create(entity); + } + + @ApiExcludeEndpoint() // TODO + @Put(':id') + async update( + @Param('id') id: string, + @Body() entity: DeepPartial, + @CurrentUser() user: User, + ): Promise { + return this.notificationService.update(id, entity); + } + + @ApiOperation({ title: 'Delete record by admin' }) + @ApiResponse({ status: HttpStatus.NO_CONTENT, description: 'The record has been successfully deleted' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Record not found' }) + @ApiUseTags('admin') + @Roles(RolesEnum.ADMIN) + @Delete('deleteByAdmin/:id') + async deleteByAdmin(@Param('id') id: string): Promise { + return this.notificationService.update({ id: parseInt(id, 10) }, { isActive: false }); + } + + @ApiOperation({ title: 'Delete record by user' }) + @ApiResponse({ status: HttpStatus.NO_CONTENT, description: 'The record has been successfully deleted' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Record not found' }) + @Delete(':id') + async delete(@Param('id') id: string, @CurrentUser() user: User): Promise { + return this.notificationService.update( + { id: parseInt(id, 10), targetType: TargetType.USER, target: user.userId }, + { isActive: false }, + ); + } + + @ApiOperation({ title: 'Send Push Notifications. Admins only' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Push Notifications has been successfully sent.', + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid input, The response body may contain clues as to what went wrong', + }) + @ApiUseTags('admin') + @Roles(RolesEnum.ADMIN) + @Post('send') + async send(@Body() notif: SendNotificationDto) { + return this.notificationService.send(notif.id); + } +} diff --git a/apps/api/src/app/notifications/notification/notification.entity.ts b/apps/api/src/app/notifications/notification/notification.entity.ts new file mode 100644 index 000000000..6cde1926d --- /dev/null +++ b/apps/api/src/app/notifications/notification/notification.entity.ts @@ -0,0 +1,79 @@ +import { Column, CreateDateColumn, Entity, Index, UpdateDateColumn, VersionColumn } from 'typeorm'; +import { Base } from '../../core/entities/base.entity'; +import { ApiModelProperty } from '@nestjs/swagger'; +import { Exclude } from 'class-transformer'; + +export enum TargetType { + ALL = 'all', + USER = 'user', + TOPIC = 'topic', +} +export enum NotificationColor { + WARN = 'warn', + ACCENT = 'accent', + PRIMARY = 'primary', +} + +export enum NotificationIcon { + notifications = 'notifications', + notifications_active = 'notifications_active', + shopping_basket = 'shopping_basket', + eject = 'eject', + cached = 'cached', + code = 'code', +} + +@Entity('notification') +export class Notification extends Base { + @ApiModelProperty({ type: String, minLength: 10, maxLength: 100 }) + @Column() + title: string; + + @ApiModelProperty({ type: String, minLength: 10, maxLength: 100 }) + @Column() + body: string; + + @ApiModelProperty({ type: String, minLength: 3, maxLength: 20 }) + @Index() + @Column() + target: string; + + @ApiModelProperty({ type: String, enum: TargetType }) + @Column({ enum: ['all', 'user', 'topic'] }) + targetType: string; + + @ApiModelProperty({ type: String, enum: NotificationIcon, default: NotificationIcon.notifications }) + @Column({ enum: NotificationIcon, default: NotificationIcon.notifications }) + icon?: NotificationIcon; + + @ApiModelProperty({ type: String, enum: NotificationColor, default: NotificationColor.PRIMARY }) + @Column({ enum: ['warn', 'accent', 'primary'], default: NotificationColor.PRIMARY }) + color?: NotificationColor; + + @ApiModelProperty({ type: Boolean, default: false }) + @Index() + @Column({ default: false }) + read?: boolean; + + @ApiModelProperty({ type: Boolean, default: false }) + @Index() + @Column({ default: false }) + native?: boolean; + + @ApiModelProperty({ type: 'string', format: 'date-time', example: '2018-11-21T06:20:32.232Z' }) + @CreateDateColumn() + createdAt?: Date; + + @ApiModelProperty({ type: 'string', format: 'date-time', example: '2018-11-21T06:20:32.232Z' }) + @UpdateDateColumn() + updatedAt?: Date; + + @Exclude() + @VersionColumn() + version?: number; + + @ApiModelProperty({ type: Boolean, default: true }) + @Index() + @Column({ default: true }) + isActive: boolean; +} diff --git a/apps/api/src/app/notifications/notifications.service.spec.ts b/apps/api/src/app/notifications/notification/notification.service.spec.ts similarity index 57% rename from apps/api/src/app/notifications/notifications.service.spec.ts rename to apps/api/src/app/notifications/notification/notification.service.spec.ts index ed8c7d51d..ae28164f8 100644 --- a/apps/api/src/app/notifications/notifications.service.spec.ts +++ b/apps/api/src/app/notifications/notification/notification.service.spec.ts @@ -1,18 +1,18 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { NotificationsService } from './notifications.service'; +import { NotificationService } from './notification.service'; -describe('NotificationsService', () => { - let service: NotificationsService; +describe('NotificationService', () => { + let service: NotificationService; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ { - provide: NotificationsService, + provide: NotificationService, useValue: {}, // TODO: Mock }, ], }).compile(); - service = module.get(NotificationsService); + service = module.get(NotificationService); }); it('should be defined', () => { expect(service).toBeDefined(); diff --git a/apps/api/src/app/notifications/notification/notification.service.ts b/apps/api/src/app/notifications/notification/notification.service.ts new file mode 100644 index 000000000..475ee6b53 --- /dev/null +++ b/apps/api/src/app/notifications/notification/notification.service.ts @@ -0,0 +1,80 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Any, In, Repository } from 'typeorm'; +import { CrudService } from '../../core'; +import { Notification, TargetType } from './notification.entity'; +import { EventBusGateway } from '../../shared'; +import { MarkAsRead, DeleteNotification } from '../index'; +import { User } from '../../auth'; +import { SubscriptionService } from '../subscription/subscription.service'; +import { PushService } from './push.service'; +import { Subscription } from '../subscription/subscription.entity'; + +@Injectable() +export class NotificationService extends CrudService { + constructor( + private readonly pushService: PushService, + private readonly eventBus: EventBusGateway, + private readonly subscriptionService: SubscriptionService, + @InjectRepository(Notification) private readonly notificationsRepository: Repository, + ) { + super(notificationsRepository); + } + + async onModuleInit() { + this.eventBus.on(MarkAsRead.type, this.onMarkAsRead.bind(this)); + this.eventBus.on(DeleteNotification.type, this.onDeleteNotification.bind(this)); + } + onModuleDestroy() { + this.eventBus.off(MarkAsRead.type, this.onMarkAsRead.bind(this)); + this.eventBus.off(DeleteNotification.type, this.onDeleteNotification.bind(this)); + } + + public async getUserNotifications(user: User): Promise<[Notification[], number]> { + const records = await this.repository.findAndCount({ target: In(['all', user.userId]), isActive: true }); + if (records[1] === 0) { + throw new NotFoundException(`The requested records were not found`); + } + return records; + } + + async send(id: string | number) { + const notification = await this.getOne(id); + + const pushNotification = { + title: notification.title, + body: notification.body, + icon: 'assets/icons/icon-72x72.png', + data: { + click_url: '/dashboard', + }, + vibrate: [200, 100, 200], + }; + + let subscriptions: Subscription[], count: number; + switch (notification.targetType) { + case TargetType.USER: + [subscriptions, count] = await this.subscriptionService.findAndCount({ userId: notification.target }); + break; + case TargetType.TOPIC: + // FIXME: https://github.com/typeorm/typeorm/issues/3150 + [subscriptions, count] = await this.subscriptionService.findAndCount({ topics: Any([notification.target]) }); + break; + case TargetType.ALL: + [subscriptions, count] = await this.subscriptionService.getAll({ take: 10 }); // TODO: for now, lets send to 10 + break; + } + + subscriptions.forEach(subscription => { + const { endpoint, p256dh, auth } = subscription; + return this.pushService.sendNotification({ endpoint, keys: { p256dh, auth } }, pushNotification as any); + }); + } + + async onMarkAsRead(action: MarkAsRead) { + await this.update({ id: parseInt(action.payload.id, 10) }, { read: true }); + } + async onDeleteNotification(action: DeleteNotification) { + await this.update({ id: parseInt(action.payload.id, 10) }, { isActive: false }); + } +} diff --git a/apps/api/src/app/push/push.service.spec.ts b/apps/api/src/app/notifications/notification/push.service.spec.ts similarity index 100% rename from apps/api/src/app/push/push.service.spec.ts rename to apps/api/src/app/notifications/notification/push.service.spec.ts diff --git a/apps/api/src/app/notifications/notification/push.service.ts b/apps/api/src/app/notifications/notification/push.service.ts new file mode 100644 index 000000000..47267d42a --- /dev/null +++ b/apps/api/src/app/notifications/notification/push.service.ts @@ -0,0 +1,29 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { PushSubscription, sendNotification, setVapidDetails, WebPushError } from 'web-push'; +import { environment as env } from '@env-api/environment'; +import { SubscriptionService } from '../subscription/subscription.service'; + +@Injectable() +export class PushService { + private readonly logger = new Logger(PushService.name); + + constructor(private readonly subscriptionService: SubscriptionService) { + setVapidDetails(env.webPush.subject, env.webPush.publicKey, env.webPush.privateKey); + } + + async sendNotification(subscription: PushSubscription, notification: Notification) { + try { + await sendNotification(subscription, JSON.stringify({ notification })); + } catch (err /*err is WebPushError*/) { + const error = err as WebPushError; + if (error.statusCode === 410) { + // delete orphaned subscriptions + this.logger.log('deleted orphaned subscription', error.message); + await this.subscriptionService.delete({ endpoint: subscription.endpoint }); + } else { + this.logger.log('send push notification failed', error.message); + } + throw new BadRequestException(err); + } + } +} diff --git a/apps/api/src/app/notifications/notifications.controller.spec.ts b/apps/api/src/app/notifications/notifications.controller.spec.ts deleted file mode 100644 index f32d271d7..000000000 --- a/apps/api/src/app/notifications/notifications.controller.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { NotificationsController } from './notifications.controller'; -import { NotificationsService } from './notifications.service'; - -describe('Notifications Controller', () => { - let module: TestingModule; - beforeAll(async () => { - module = await Test.createTestingModule({ - controllers: [NotificationsController], - providers: [ - { - provide: NotificationsService, - useValue: {}, // TODO: Mock - }, - ], - }).compile(); - }); - it('should be defined', () => { - const controller: NotificationsController = module.get(NotificationsController); - expect(controller).toBeDefined(); - }); -}); diff --git a/apps/api/src/app/notifications/notifications.controller.ts b/apps/api/src/app/notifications/notifications.controller.ts deleted file mode 100644 index 8820715bc..000000000 --- a/apps/api/src/app/notifications/notifications.controller.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Body, Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common'; -import { CrudController } from '../core'; -import { ApiOAuth2Auth, ApiOperation, ApiResponse, ApiUseTags } from '@nestjs/swagger'; -import { Notification } from './notification.entity'; -import { CreateNotificationDto } from './dto/create-notification.dto'; -import { NotificationsService } from './notifications.service'; -import { CurrentUser, Roles, RolesEnum } from '../auth'; -import { AddNotification } from './interfaces/notification.actions'; -import * as uuid from 'uuid'; - -@ApiOAuth2Auth(['read']) -@ApiUseTags('Sumo', 'Notifications') -@Controller() -export class NotificationsController extends CrudController { - constructor(private readonly notificationsService: NotificationsService) { - super(notificationsService); - } - - @ApiOperation({ title: 'find all user Notifications' }) - @ApiResponse({ status: HttpStatus.OK, description: 'All user Notifications', /* type: T, */ isArray: true }) - @Get() - async findAll(@CurrentUser() user): Promise<[Notification[], number]> { - return this.notificationsService.getUserNotifications(user); - } - - @ApiOperation({ title: 'Create new record' }) - @ApiResponse({ - status: HttpStatus.CREATED, - description: 'The record has been successfully created.', - type: Notification, - }) - @ApiResponse({ - status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong', - }) - @Post() - async create(@Body() entity: CreateNotificationDto): Promise { - return super.create(entity); - } - - // TODO: add to database for offline users and also show to active users - @ApiOperation({ title: 'Notify User' }) - @ApiResponse({ status: HttpStatus.ACCEPTED, description: 'Accepted' }) - @Roles(RolesEnum.USER) - @Post('/notify') - @HttpCode(HttpStatus.ACCEPTED) - notify(@Body() notif: CreateNotificationDto, @CurrentUser() user) { - this.notificationsService.notify( - user, - new AddNotification({ - id: uuid(), - icon: notif.icon, - message: notif.message, - createdAt: new Date(), - read: notif.read, - color: notif.color, - }), - ); - } - - // TODO: add to database for offline users and also show to active users - @ApiOperation({ title: 'Notify All' }) - @ApiResponse({ status: HttpStatus.ACCEPTED, description: 'Accepted' }) - @Roles(RolesEnum.ADMIN) - @Post('/notifyAll') - @HttpCode(HttpStatus.ACCEPTED) - notifyAll(@Body() notif: CreateNotificationDto) { - const date = new Date(); - this.notificationsService.notifyAll( - new AddNotification({ - id: uuid(), - icon: notif.icon, - message: notif.message, - createdAt: date.setTime(date.getTime() - 2 * 86400000), - read: notif.read, - color: notif.color, - }), - ); - } -} diff --git a/apps/api/src/app/notifications/notifications.module.ts b/apps/api/src/app/notifications/notifications.module.ts index 08ca2bb96..28f390c7f 100644 --- a/apps/api/src/app/notifications/notifications.module.ts +++ b/apps/api/src/app/notifications/notifications.module.ts @@ -1,15 +1,18 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { SharedModule } from '../shared'; -import { Notification } from './notification.entity'; - -import { NotificationsService } from './notifications.service'; -import { NotificationsController } from './notifications.controller'; +import { NotificationController } from './notification/notification.controller'; +import { NotificationService } from './notification/notification.service'; +import { SubscriptionController } from './subscription/subscription.controller'; +import { SubscriptionService } from './subscription/subscription.service'; +import { PushService } from './notification/push.service'; +import { Notification } from './notification/notification.entity'; +import { Subscription } from './subscription/subscription.entity'; @Module({ // imports: [SharedModule, TypeOrmModule.forFeature([NotificationsRepository])], - imports: [SharedModule, TypeOrmModule.forFeature([Notification])], - providers: [NotificationsService], - controllers: [NotificationsController], + imports: [SharedModule, TypeOrmModule.forFeature([Notification, Subscription])], + providers: [PushService, SubscriptionService, NotificationService], + controllers: [NotificationController, SubscriptionController], }) export class NotificationsModule {} diff --git a/apps/api/src/app/notifications/notifications.service.ts b/apps/api/src/app/notifications/notifications.service.ts deleted file mode 100644 index 95b6649e7..000000000 --- a/apps/api/src/app/notifications/notifications.service.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { CrudService } from '../core'; -import { Notification } from './notification.entity'; -import { EventBusGateway } from '../shared'; -import { AddNotification, DeleteNotification } from './index'; -import { User } from '../auth'; - -@Injectable() -export class NotificationsService extends CrudService { - constructor( - private readonly eventBus: EventBusGateway, - @InjectRepository(Notification) private readonly notificationsRepository: Repository, - ) { - super(notificationsRepository); - } - - async onModuleInit() { - this.eventBus.on(AddNotification.type, this.onAddNotification.bind(this)); - this.eventBus.on(DeleteNotification.type, this.onDeleteNotification.bind(this)); - } - onModuleDestroy() { - this.eventBus.off(AddNotification.type, this.onAddNotification.bind(this)); - this.eventBus.off(DeleteNotification.type, this.onDeleteNotification.bind(this)); - } - - public async getUserNotifications(user: User): Promise<[Notification[], number]> { - const records = await this.repository.findAndCount({ userId: user.userId }); - if (records[1] === 0) { - throw new NotFoundException(`The requested records were not found`); - } - return records; - } - - notify(user: User, action: any) { - this.eventBus.sendActionToUser(user, action); - } - notifyAll(action: any) { - this.eventBus.sendActionToAll(action); - } - - onAddNotification(action: AddNotification) { - console.log('AddNotification', action); - } - - // TODO: sync with database - onDeleteNotification(action: DeleteNotification) { - console.log('DeleteNotification', action); - } -} diff --git a/apps/api/src/app/push/dto/create-subscription.dto.ts b/apps/api/src/app/notifications/subscription/dto/create-subscription.dto.ts similarity index 52% rename from apps/api/src/app/push/dto/create-subscription.dto.ts rename to apps/api/src/app/notifications/subscription/dto/create-subscription.dto.ts index 4115eff1b..144c3a7a7 100644 --- a/apps/api/src/app/push/dto/create-subscription.dto.ts +++ b/apps/api/src/app/notifications/subscription/dto/create-subscription.dto.ts @@ -1,5 +1,5 @@ -import { ApiModelProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString, IsUrl } from 'class-validator'; +import { ApiModelProperty, ApiModelPropertyOptional } from '@nestjs/swagger'; +import { ArrayUnique, IsNotEmpty, IsOptional, IsString, IsUrl } from 'class-validator'; export class CreateSubscriptionDto { @ApiModelProperty({ type: String }) @@ -17,7 +17,10 @@ export class CreateSubscriptionDto { @IsString() p256dh: string; - @ApiModelProperty({ type: String, isArray: true }) - @IsNotEmpty() + @ApiModelPropertyOptional({ type: String, isArray: true }) + @IsOptional() + @ArrayUnique() + // @IsEnum(TopicsEnum, { each: true }) + @IsString({ each: true }) topics: string[]; } diff --git a/apps/api/src/app/push/dto/update-subscription.dto.ts b/apps/api/src/app/notifications/subscription/dto/update-subscription.dto.ts similarity index 62% rename from apps/api/src/app/push/dto/update-subscription.dto.ts rename to apps/api/src/app/notifications/subscription/dto/update-subscription.dto.ts index 908b8b259..cd8f6fd93 100644 --- a/apps/api/src/app/push/dto/update-subscription.dto.ts +++ b/apps/api/src/app/notifications/subscription/dto/update-subscription.dto.ts @@ -1,8 +1,9 @@ import { ApiModelProperty } from '@nestjs/swagger'; -import { IsNotEmpty } from 'class-validator'; +import { ArrayNotEmpty, ArrayUnique } from 'class-validator'; export class UpdateSubscriptionDto { @ApiModelProperty({ type: String, isArray: true }) - @IsNotEmpty() + @ArrayNotEmpty() + @ArrayUnique() topics: string[]; } diff --git a/apps/api/src/app/notifications/subscription/subscription.controller.spec.ts b/apps/api/src/app/notifications/subscription/subscription.controller.spec.ts new file mode 100644 index 000000000..26a37dc67 --- /dev/null +++ b/apps/api/src/app/notifications/subscription/subscription.controller.spec.ts @@ -0,0 +1,22 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SubscriptionController } from './subscription.controller'; +import { SubscriptionService } from './subscription.service'; + +describe('Subscription Controller', () => { + let module: TestingModule; + beforeAll(async () => { + module = await Test.createTestingModule({ + controllers: [SubscriptionController], + providers: [ + { + provide: SubscriptionService, + useValue: {}, // TODO: Mock + }, + ], + }).compile(); + }); + it('should be defined', () => { + const controller: SubscriptionController = module.get(SubscriptionController); + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/api/src/app/notifications/subscription/subscription.controller.ts b/apps/api/src/app/notifications/subscription/subscription.controller.ts new file mode 100644 index 000000000..05ef585ee --- /dev/null +++ b/apps/api/src/app/notifications/subscription/subscription.controller.ts @@ -0,0 +1,96 @@ +import { Body, Controller, Delete, Get, HttpStatus, Param, Post, Put } from '@nestjs/common'; +import { ApiOAuth2Auth, ApiOperation, ApiResponse, ApiUseTags } from '@nestjs/swagger'; +import { CrudController } from '../../core'; +import { Subscription } from './subscription.entity'; +import { SubscriptionService } from './subscription.service'; +import { CurrentUser, Roles, RolesEnum } from '../../auth/decorators'; +import { User } from '../../auth'; +import { CreateSubscriptionDto } from './dto/create-subscription.dto'; +import { UpdateSubscriptionDto } from './dto/update-subscription.dto'; + +@ApiOAuth2Auth(['read']) +@ApiUseTags('Sumo', 'Subscription') +@Controller('subscription') +export class SubscriptionController extends CrudController { + constructor(private readonly subscriptionService: SubscriptionService) { + super(subscriptionService); + } + + @ApiOperation({ title: 'find all Subscriptions. Admins only' }) + @ApiResponse({ status: HttpStatus.OK, description: 'All Subscriptions', /* type: Subscription, */ isArray: true }) + @ApiUseTags('admin') + @Roles(RolesEnum.ADMIN) + @Get() + async findAll(): Promise<[Subscription[], number]> { + return this.subscriptionService.getAll(); + } + + @ApiOperation({ title: 'find all user Subscriptions' }) + @ApiResponse({ status: HttpStatus.OK, description: 'All user Subscriptions', /* type: Subscription, */ isArray: true }) + @Get('user') + async getUserSubscriptions(@CurrentUser() user): Promise<[Subscription[], number]> { + return this.subscriptionService.getUserSubscriptions(user); + } + + @ApiOperation({ title: 'Find by id. Admins only' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Found one record', type: Subscription }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Record not found' }) + @ApiUseTags('admin') + @Roles(RolesEnum.ADMIN) + @Get(':id') + async findById(@Param('id') id: string): Promise { + return super.findById(id); + } + + @ApiOperation({ title: 'Create new record' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'The record has been successfully created.', + type: Subscription, + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid input, The response body may contain clues as to what went wrong', + }) + @Post() + async create(@Body() entity: CreateSubscriptionDto, @CurrentUser() user: User): Promise { + const records = await this.subscriptionService.findAndCount({ endpoint: entity.endpoint }); + if (records[1] === 0) { + return super.create({ ...entity, userId: user.userId }); + } else { + return records[0][0]; + } + } + + @ApiOperation({ title: 'Update an existing record' }) + @ApiResponse({ status: HttpStatus.CREATED, description: 'The record has been successfully edited.' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Record not found' }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid input, The response body may contain clues as to what went wrong', + }) + @Put(':id') + async update( + @Param('id') id: string, + @Body() entity: UpdateSubscriptionDto, + @CurrentUser() user: User, + ): Promise { + if (id.startsWith('http')) { + return this.subscriptionService.update({ endpoint: id, userId: user.userId }, entity); + } else { + return this.subscriptionService.update({ id: parseInt(id, 10), userId: user.userId }, entity); + } + } + + @ApiOperation({ title: 'Delete record' }) + @ApiResponse({ status: HttpStatus.NO_CONTENT, description: 'The record has been successfully deleted' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Record not found' }) + @Delete(':id') + async delete(@Param('id') id: string, @CurrentUser() user: User): Promise { + if (id.startsWith('http')) { + return this.subscriptionService.delete({ endpoint: id, userId: user.userId }); + } else { + return this.subscriptionService.delete({ id: parseInt(id, 10), userId: user.userId }); + } + } +} diff --git a/apps/api/src/app/push/subscription.entity.ts b/apps/api/src/app/notifications/subscription/subscription.entity.ts similarity index 66% rename from apps/api/src/app/push/subscription.entity.ts rename to apps/api/src/app/notifications/subscription/subscription.entity.ts index c1070d6d7..1a5253899 100644 --- a/apps/api/src/app/push/subscription.entity.ts +++ b/apps/api/src/app/notifications/subscription/subscription.entity.ts @@ -1,41 +1,30 @@ import { Column, CreateDateColumn, Entity, Index, UpdateDateColumn, VersionColumn } from 'typeorm'; import { ApiModelProperty } from '@nestjs/swagger'; -import { IsAscii, IsNotEmpty, IsString, IsUrl, MaxLength, MinLength } from 'class-validator'; import { Exclude } from 'class-transformer'; -import { Base } from '../core/entities/base.entity'; +import { Base } from '../../core/entities/base.entity'; @Entity('subscription') export class Subscription extends Base { @ApiModelProperty({ type: String }) - @IsNotEmpty() - @IsUrl({}, { message: 'endpoint must be a valid url.' }) @Index({ unique: true }) @Column() endpoint: string; @ApiModelProperty({ type: String }) - @IsNotEmpty() - @IsString() - @Column({}) + @Column() auth: string; @ApiModelProperty({ type: String }) - @IsNotEmpty() - @IsString() - @Column({}) + @Column() p256dh: string; - @ApiModelProperty({ type: String, minLength: 8, maxLength: 20 }) - @IsAscii() - @IsNotEmpty() - @MinLength(8) - @MaxLength(20) + @ApiModelProperty({ type: String, minLength: 3, maxLength: 20 }) @Index() @Column() userId: string; @ApiModelProperty({ type: String, isArray: true }) - @Column('text', { array: true }) + @Column('text', { nullable: true, array: true }) topics: string[]; @ApiModelProperty({ type: 'string', format: 'date-time', example: '2018-11-21T06:20:32.232Z' }) diff --git a/apps/api/src/app/notifications/subscription/subscription.service.spec.ts b/apps/api/src/app/notifications/subscription/subscription.service.spec.ts new file mode 100644 index 000000000..e4a3b6cd6 --- /dev/null +++ b/apps/api/src/app/notifications/subscription/subscription.service.spec.ts @@ -0,0 +1,15 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SubscriptionService } from './subscription.service'; + +describe('SubscriptionService', () => { + let service: SubscriptionService; + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SubscriptionService], + }).compile(); + service = module.get(SubscriptionService); + }); + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/api/src/app/notifications/subscription/subscription.service.ts b/apps/api/src/app/notifications/subscription/subscription.service.ts new file mode 100644 index 000000000..e5b097558 --- /dev/null +++ b/apps/api/src/app/notifications/subscription/subscription.service.ts @@ -0,0 +1,42 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { CrudService } from '../../core'; +import { Subscription } from './subscription.entity'; +import { FindConditions, Repository } from 'typeorm'; +import { setVapidDetails } from 'web-push'; +import { environment as env } from '@env-api/environment'; +import { User } from '../../auth'; + +@Injectable() +export class SubscriptionService extends CrudService { + private readonly logger = new Logger(SubscriptionService.name); + + constructor(@InjectRepository(Subscription) private readonly subscriptionRepository: Repository) { + super(subscriptionRepository); + setVapidDetails(env.webPush.subject, env.webPush.publicKey, env.webPush.privateKey); + } + + async findAndCount(conditions?: FindConditions): Promise<[Subscription[], number]> { + return this.subscriptionRepository.findAndCount(conditions); + } + + async find(conditions?: FindConditions): Promise { + return this.subscriptionRepository.find(conditions); + } + + public async getUserSubscriptions(user: User): Promise<[Subscription[], number]> { + const records = await this.repository.findAndCount({ userId: user.userId }); + if (records[1] === 0) { + throw new NotFoundException(`The requested records were not found`); + } + return records; + } + + async getOne(id: string | number | FindConditions): Promise { + if (typeof id === 'string' && id.startsWith('http')) { + return super.getOne({ endpoint: id }); + } else { + return super.getOne(id); + } + } +} diff --git a/apps/api/src/app/push/dto/send-notification.dto.ts b/apps/api/src/app/push/dto/send-notification.dto.ts deleted file mode 100644 index e43dbc215..000000000 --- a/apps/api/src/app/push/dto/send-notification.dto.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ApiModelProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; - -export class SendNotificationDto { - @ApiModelProperty({ type: String }) - @IsNotEmpty() - @IsString() - id: string; - - @ApiModelProperty({ type: String }) - @IsNotEmpty() - @IsString() - title: string; - - @ApiModelProperty({ type: String }) - @IsNotEmpty() - @IsString() - body: string; -} diff --git a/apps/api/src/app/push/dto/send-topic-notification.dto.ts b/apps/api/src/app/push/dto/send-topic-notification.dto.ts deleted file mode 100644 index 8d20e89bf..000000000 --- a/apps/api/src/app/push/dto/send-topic-notification.dto.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ApiModelProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; - -export class SendTopicNotificationDto { - @ApiModelProperty({ type: String }) - @IsNotEmpty() - @IsString() - topic: string; - - @ApiModelProperty({ type: String }) - @IsNotEmpty() - @IsString() - title: string; - - @ApiModelProperty({ type: String }) - @IsNotEmpty() - @IsString() - body: string; -} diff --git a/apps/api/src/app/push/index.ts b/apps/api/src/app/push/index.ts deleted file mode 100644 index b69327291..000000000 --- a/apps/api/src/app/push/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './push.module'; diff --git a/apps/api/src/app/push/push.controller.spec.ts b/apps/api/src/app/push/push.controller.spec.ts deleted file mode 100644 index 0120956f6..000000000 --- a/apps/api/src/app/push/push.controller.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { PushController } from './push.controller'; -import { PushService } from './push.service'; - -describe('Push Controller', () => { - let module: TestingModule; - - beforeAll(async () => { - module = await Test.createTestingModule({ - controllers: [PushController], - providers: [ - { - provide: PushService, - useValue: {}, // TODO: Mock - }, - ], - }).compile(); - }); - it('should be defined', () => { - const controller: PushController = module.get(PushController); - expect(controller).toBeDefined(); - }); -}); diff --git a/apps/api/src/app/push/push.controller.ts b/apps/api/src/app/push/push.controller.ts deleted file mode 100644 index 07995c341..000000000 --- a/apps/api/src/app/push/push.controller.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { Body, Controller, Delete, HttpStatus, Param, Post, Put } from '@nestjs/common'; -import { ApiOAuth2Auth, ApiOperation, ApiResponse, ApiUseTags } from '@nestjs/swagger'; -import { CrudController } from '../core'; -import { CurrentUser, User } from '../auth'; -import { Subscription } from './subscription.entity'; -import { PushService } from './push.service'; -import { CreateSubscriptionDto } from './dto/create-subscription.dto'; -import { UpdateSubscriptionDto } from './dto/update-subscription.dto'; -import { SendNotificationDto } from './dto/send-notification.dto'; -import { SendTopicNotificationDto } from './dto/send-topic-notification.dto'; - -@ApiOAuth2Auth(['read']) -@ApiUseTags('Sumo', 'Push') -@Controller() -export class PushController extends CrudController { - constructor(private readonly pushService: PushService) { - super(pushService); - } - - @ApiOperation({ title: 'Create new record' }) - @ApiResponse({ - status: HttpStatus.CREATED, - description: 'The record has been successfully created.', - type: Subscription, - }) - @ApiResponse({ - status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong', - }) - @Post() - async create(@Body() entity: CreateSubscriptionDto, @CurrentUser() user: User): Promise { - const records = await this.pushService.findAndCount({ endpoint: entity.endpoint }); - if (records[1] === 0) { - return super.create({ ...entity, userId: user.userId }); - } else { - return records[0][0]; - } - } - - @ApiOperation({ title: 'Update an existing record' }) - @ApiResponse({ status: HttpStatus.CREATED, description: 'The record has been successfully edited.' }) - @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Record not found' }) - @ApiResponse({ - status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong', - }) - @Put(':id') - async update( - @Param('id') id: string, - @Body() entity: UpdateSubscriptionDto, - @CurrentUser() user: User, - ): Promise { - if (id.startsWith('http')) { - return this.pushService.update({ endpoint: id }, entity); - } else { - return this.pushService.update(id, entity); - } - } - - @ApiOperation({ title: 'Delete record' }) - @ApiResponse({ status: HttpStatus.NO_CONTENT, description: 'The record has been successfully deleted' }) - @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Record not found' }) - @Delete(':id') - async delete(@Param('id') id: string, @CurrentUser() user: User): Promise { - if (id.startsWith('http')) { - return this.pushService.delete({ endpoint: id }); - } else { - return this.pushService.delete(id); - } - } - - @ApiOperation({ title: 'Send Push Notification' }) - @ApiResponse({ - status: HttpStatus.CREATED, - description: 'Push Notification has been successfully sent.', - }) - @ApiResponse({ - status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong', - }) - @Post('notify') - notify(@Body() notif: SendNotificationDto) { - const notification = { - title: notif.title, - body: notif.body, - icon: 'assets/icons/icon-72x72.png', - data: { - click_url: '/dashboard', - }, - }; - return this.pushService.notify(notif.id, notification as any); - } - - @ApiOperation({ title: 'Send Push Notifications to all subscribers to a topic' }) - @ApiResponse({ - status: HttpStatus.CREATED, - description: 'Push Notifications has been successfully sent.', - }) - @ApiResponse({ - status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong', - }) - @Post('notifyAll') - notifyAll(@Body() notif: SendTopicNotificationDto) { - const notification = { - title: notif.title, - body: notif.body, - icon: 'assets/icons/icon-72x72.png', - data: { - click_url: '/dashboard', - }, - }; - return this.pushService.notifyAll(notif.topic, notification as any); - } -} diff --git a/apps/api/src/app/push/push.module.ts b/apps/api/src/app/push/push.module.ts deleted file mode 100644 index a7197e735..000000000 --- a/apps/api/src/app/push/push.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { PushController } from './push.controller'; -import { PushService } from './push.service'; -import { Subscription } from './subscription.entity'; - -@Module({ - imports: [TypeOrmModule.forFeature([Subscription])], - providers: [PushService], - controllers: [PushController], -}) -export class PushModule {} diff --git a/apps/api/src/app/push/push.service.ts b/apps/api/src/app/push/push.service.ts deleted file mode 100644 index 2750df525..000000000 --- a/apps/api/src/app/push/push.service.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { BadRequestException, Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { PushSubscription, sendNotification, setVapidDetails, WebPushError } from 'web-push'; -import { CrudService } from '../core'; -import { Any, FindConditions, Repository } from 'typeorm'; -import { Subscription } from './subscription.entity'; -import { environment as env } from '@env-api/environment'; - -@Injectable() -export class PushService extends CrudService { - private readonly logger = new Logger(PushService.name); - - constructor(@InjectRepository(Subscription) private readonly subscriptionRepository: Repository) { - super(subscriptionRepository); - setVapidDetails(env.webPush.subject, env.webPush.publicKey, env.webPush.privateKey); - } - async findAndCount(conditions?: FindConditions): Promise<[Subscription[], number]> { - return this.subscriptionRepository.findAndCount(conditions); - } - - async getOne(id: string | number | FindConditions): Promise { - if (typeof id === 'string' && id.startsWith('http')) { - return super.getOne({ endpoint: id }); - } else { - return super.getOne(id); - } - } - - async notify(id: string | number, notification: Notification) { - const { endpoint, p256dh, auth } = await this.getOne(id); - return this._sendNotification({ endpoint, keys: { p256dh, auth } }, notification); - } - - async notifyAll(topic: string, notification: Notification) { - // FIXME: https://github.com/typeorm/typeorm/issues/3150 - const subscriptions = await this.findAndCount({ topics: Any([topic]) } ); - // console.log(subscriptions); - if (subscriptions[1] > 0) { - subscriptions[0].forEach( sub => { - const { endpoint, p256dh, auth } = sub; - this._sendNotification({ endpoint, keys: { p256dh, auth } }, notification); - }); - } - } - - private async _sendNotification(subscription: PushSubscription, notification: Notification) { - try { - await sendNotification(subscription, JSON.stringify({ notification })); - } catch (err /*err is WebPushError*/) { - const error = err as WebPushError; - if (error.statusCode === 410) { - // delete orphaned subscriptions - this.logger.log('deleted orphaned subscription', error.message); - await super.delete({ endpoint: subscription.endpoint }); - } else { - this.logger.log('send push notification failed', error.message); - } - throw new BadRequestException(err); - } - } -} diff --git a/apps/webapp/src/assets/data/notifications.json b/apps/webapp/src/assets/data/notifications.json index 50a762937..9f29dc583 100644 --- a/apps/webapp/src/assets/data/notifications.json +++ b/apps/webapp/src/assets/data/notifications.json @@ -1,45 +1,61 @@ [ [ { - "id": "abc1", + "id": 11, + "title": "title for sumo3 next", + "body": "sample body for sumo3 next", + "target": "sumo3", + "targetType": "user", "icon": "notifications", - "message": "This is a notification", - "createdAt": "2017-12-01T23:44:28+00:00", + "color": "primary", "read": false, - "color": "warn" + "native": true, + "createdAt": "2018-12-04T16:26:11.107Z", + "updatedAt": "2018-12-04T16:26:11.107Z", + "isActive": true }, { - "id": "abc2", - "icon": "shopping_basket", - "message": "User bought your template", - "createdAt": "2018-07-22T23:44:28+00:00", + "id": 9, + "title": "title for sumo3 2", + "body": "sample body for sumo3 2", + "target": "sumo3", + "targetType": "user", + "icon": "notifications", + "color": "primary", "read": false, - "color": "primary" + "native": false, + "createdAt": "2018-12-04T16:25:33.506Z", + "updatedAt": "2018-12-04T16:25:33.506Z", + "isActive": true }, { - "id": "abc3", - "icon": "eject", - "message": "Server Crashed", - "createdAt": "2018-07-20T23:44:28+00:00", + "id": 7, + "title": "title for sumo3", + "body": "sample body for sumo3", + "target": "sumo3", + "targetType": "user", + "icon": "notifications", + "color": "primary", "read": false, - "color": "accent" - }, - { - "id": "abc4", - "icon": "cached", - "message": "New user registered", - "createdAt": "2018-07-11T23:44:28+00:00", - "read": true, - "color": "" + "native": true, + "createdAt": "2018-12-04T16:24:49.813Z", + "updatedAt": "2018-12-04T16:24:49.813Z", + "isActive": true }, { - "id": "abc5", - "icon": "code", - "message": "John added you as friend", - "createdAt": "2018-07-07T23:44:28+00:00", - "read": true, - "color": "" + "id": 2, + "title": "title for all", + "body": "sample body for all", + "target": "all", + "targetType": "all", + "icon": "notifications", + "color": "primary", + "read": false, + "native": false, + "createdAt": "2018-12-04T16:17:32.512Z", + "updatedAt": "2018-12-04T16:35:30.142Z", + "isActive": true } ], - 5 + 4 ] diff --git a/libs/core/src/lib/state/auth.handler.ts b/libs/auth/src/lib/auth.handler.ts similarity index 80% rename from libs/core/src/lib/state/auth.handler.ts rename to libs/auth/src/lib/auth.handler.ts index 5b7d06d0f..b5d7dba6f 100644 --- a/libs/core/src/lib/state/auth.handler.ts +++ b/libs/auth/src/lib/auth.handler.ts @@ -1,7 +1,7 @@ import { Actions, ofActionErrored, ofActionSuccessful } from '@ngxs/store'; import { Injectable } from '@angular/core'; -import { Login, LoginSuccess } from '@ngx-starter-kit/auth'; -import { GoogleAnalyticsService } from '../services/google-analytics.service'; +import { Login, LoginSuccess } from './auth.actions'; +import { GoogleAnalyticsService } from '@ngx-starter-kit/core/src/lib/services/google-analytics.service'; @Injectable({ providedIn: 'root', diff --git a/libs/auth/src/lib/auth.handler.ts.todo b/libs/auth/src/lib/auth.handler.ts.todo deleted file mode 100644 index 1d03c041d..000000000 --- a/libs/auth/src/lib/auth.handler.ts.todo +++ /dev/null @@ -1,17 +0,0 @@ -import { Actions, ofActionErrored, ofActionSuccessful } from '@ngxs/store'; -import { Injectable } from '@angular/core'; -import { Login, LoginSuccess } from '@ngx-starter-kit/auth'; -import { GoogleAnalyticsService } from '@ngx-starter-kit/core'; - -@Injectable({ - providedIn: 'root', -}) -export class AuthHandler { - constructor(private actions$: Actions, private analytics: GoogleAnalyticsService) { - this.actions$.pipe(ofActionSuccessful(Login)).subscribe(action => console.log('Login........Action Successful')); - this.actions$.pipe(ofActionErrored(Login)).subscribe(action => console.log('Login........Action Errored')); - this.actions$.pipe(ofActionSuccessful(LoginSuccess)).subscribe((action: LoginSuccess) => { - this.analytics.setUsername(action.payload.preferred_username); - }); - } -} diff --git a/libs/auth/src/lib/auth.module.ts b/libs/auth/src/lib/auth.module.ts index 469b86df8..899791763 100644 --- a/libs/auth/src/lib/auth.module.ts +++ b/libs/auth/src/lib/auth.module.ts @@ -19,13 +19,14 @@ import { MatFormFieldModule, MatIconModule, MatInputModule, - MatTooltipModule, MatToolbarModule, + MatTooltipModule, } from '@angular/material'; import { ReactiveFormsModule } from '@angular/forms'; import { FlexLayoutModule } from '@angular/flex-layout'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { environment } from '@env/environment'; +import { AuthHandler } from './auth.handler'; @NgModule({ imports: [ @@ -67,4 +68,6 @@ export class AuthModule { providers: [{ provide: APP_INITIALIZER, useFactory: initializeAuth, deps: [OAuthService, Store], multi: true }], }; } + // HINT: AuthHandler is injected here to initialize it as Module Run Block + constructor(authHandler: AuthHandler) {} } diff --git a/libs/core/src/lib/core.module.ts b/libs/core/src/lib/core.module.ts index 976802307..d2409cd40 100644 --- a/libs/core/src/lib/core.module.ts +++ b/libs/core/src/lib/core.module.ts @@ -33,7 +33,6 @@ import { PreferenceState } from './state/preference.state'; import { AppHandler } from './state/app.handler'; import { RouteHandler } from './state/route.handler'; import { EventBusHandler } from './state/eventbus.handler'; -import { AuthHandler } from './state/auth.handler'; import { GoogleAnalyticsService } from './services/google-analytics.service'; // Noop handler for factory function @@ -89,7 +88,7 @@ library.add(faTwitter, faGithub, faGoogle); { provide: APP_INITIALIZER, useFactory: noop, - deps: [EventBusHandler, RouteHandler, AppHandler, AuthHandler], + deps: [EventBusHandler, RouteHandler, AppHandler], multi: true, }, { diff --git a/libs/core/src/lib/services/push-notification.service.ts b/libs/core/src/lib/services/push-notification.service.ts index dd8f460b8..78b954124 100644 --- a/libs/core/src/lib/services/push-notification.service.ts +++ b/libs/core/src/lib/services/push-notification.service.ts @@ -10,8 +10,7 @@ import { HttpClient, HttpErrorResponse } from '@angular/common/http'; }) export class PushNotificationService { public baseUrl = environment.API_BASE_URL; - private readonly entityPath = 'push'; - private readonly existingSubscription: PushSubscription; + private readonly entityPath = 'subscription'; constructor(private readonly swPush: SwPush, private httpClient: HttpClient) {} @@ -31,7 +30,7 @@ export class PushNotificationService { } = subscription.toJSON(); console.log('push subscription created', { endpoint, auth, p256dh }); await this.httpClient - .post(`${this.baseUrl}/${this.entityPath}`, { endpoint, auth, p256dh, topics: ['sumo1', 'sumo2'] }) + .post(`${this.baseUrl}/${this.entityPath}`, { endpoint, auth, p256dh }) .pipe(catchError(this.handleError)) .toPromise(); } @@ -53,17 +52,6 @@ export class PushNotificationService { } } - async notify(id: string) { - await this.httpClient - .post(`${this.baseUrl}/${this.entityPath}/notify`, { - id: encodeURIComponent(id), - title: 'NGX WebApp Notification', - body: 'test body 321', - }) - .pipe(catchError(this.handleError)) - .toPromise(); - } - private handleError(error: HttpErrorResponse) { if (error.error instanceof ErrorEvent) { // A client-side or network error occurred. Handle it accordingly. diff --git a/libs/notifications/src/lib/app-notification.model.ts b/libs/notifications/src/lib/app-notification.model.ts index 30ce35c72..25a8b1e53 100644 --- a/libs/notifications/src/lib/app-notification.model.ts +++ b/libs/notifications/src/lib/app-notification.model.ts @@ -1,11 +1,36 @@ import { Entity } from '@ngx-starter-kit/shared'; +export enum TargetType { + ALL = 'all', + USER = 'user', + TOPIC = 'topic', +} +export enum NotificationColor { + WARN = 'warn', + ACCENT = 'accent', + PRIMARY = 'primary', +} + +export enum NotificationIcon { + notifications = 'notifications', + notifications_active = 'notifications_active', + shopping_basket = 'shopping_basket', + eject = 'eject', + cached = 'cached', + code = 'code', +} + + export class AppNotification extends Entity { public id: number; - public icon: 'notifications' | 'notifications_active' | 'shopping_basket' | 'eject' | 'cached' | 'code'; - public message: string; - public createdAt: Date; + public title: string; + public body: string; + public target: string; + public targetType: TargetType; + public icon: NotificationIcon; + public color: NotificationColor; public read: boolean; - public color: 'warn' | 'accent' | 'primary'; public native: boolean; + public createdAt: Date; + public updatedAt?: Date; } diff --git a/libs/notifications/src/lib/notifications.component.html b/libs/notifications/src/lib/notifications.component.html index a19688727..0ad6bf28a 100644 --- a/libs/notifications/src/lib/notifications.component.html +++ b/libs/notifications/src/lib/notifications.component.html @@ -42,7 +42,7 @@ > {{ notification.icon }}
-
{{ notification.message }}
+
{{ notification.body }}
{{ notification.createdAt | formatTimeInWords }}
diff --git a/libs/notifications/src/lib/notifications.component.ts b/libs/notifications/src/lib/notifications.component.ts index 0c19c1450..53b1d383f 100644 --- a/libs/notifications/src/lib/notifications.component.ts +++ b/libs/notifications/src/lib/notifications.component.ts @@ -3,7 +3,6 @@ import { Select, Store } from '@ngxs/store'; import { listFadeAnimation } from '@ngx-starter-kit/animations'; import { DeleteNotification, FetchNotifications, MarkAllAsRead, MarkAsRead } from './notifications.actions'; -import { SendWebSocketAction } from '@ngx-starter-kit/socketio-plugin'; import { Observable } from 'rxjs'; import { NotificationsState } from './notifications.state'; import { AppNotification } from './app-notification.model'; @@ -34,7 +33,6 @@ export class NotificationsComponent implements OnInit { dismiss(notification, event) { event.stopPropagation(); this.store.dispatch(new DeleteNotification(notification)); - this.store.dispatch(new SendWebSocketAction(new DeleteNotification(notification))); } toggleDropdown() { diff --git a/libs/notifications/src/lib/notifications.handler.ts b/libs/notifications/src/lib/notifications.handler.ts new file mode 100644 index 000000000..99332ab89 --- /dev/null +++ b/libs/notifications/src/lib/notifications.handler.ts @@ -0,0 +1,32 @@ +import { Actions, ofActionSuccessful, Store } from '@ngxs/store'; +import { Injectable } from '@angular/core'; +import { AddNotification, DeleteNotification, MarkAllAsRead, MarkAsRead } from './notifications.actions'; +import { SendWebSocketAction } from '@ngx-starter-kit/socketio-plugin'; +import { SwPush } from '@angular/service-worker'; +import { AppNotification } from '@ngx-starter-kit/notifications'; + +@Injectable({ + providedIn: 'root', +}) +export class NotificationsHandler { + constructor(private actions$: Actions, private store: Store, private readonly swPush: SwPush) { + this.actions$ + .pipe(ofActionSuccessful(DeleteNotification)) + .subscribe(action => {console.log(action); this.store.dispatch(new SendWebSocketAction(action))}); + this.actions$ + .pipe(ofActionSuccessful(MarkAsRead)) + .subscribe(action => this.store.dispatch(new SendWebSocketAction(action))); + this.actions$ + .pipe(ofActionSuccessful(MarkAllAsRead)) + .subscribe(action => this.store.dispatch(new SendWebSocketAction(action))); + + if (this.swPush.isEnabled) { + // subscribe for new messages for testing + this.swPush.messages.subscribe(message => { + console.log('received push notification in NotificationsHandler', message); + // TODO: + // this.store.dispatch(new AddNotification(message as AppNotification)); + }); + } + } +} diff --git a/libs/notifications/src/lib/notifications.module.ts b/libs/notifications/src/lib/notifications.module.ts index f90914dab..49ff1318c 100644 --- a/libs/notifications/src/lib/notifications.module.ts +++ b/libs/notifications/src/lib/notifications.module.ts @@ -3,12 +3,16 @@ import { NotificationsComponent } from './notifications.component'; import { NgxsModule } from '@ngxs/store'; import { SharedModule } from '@ngx-starter-kit/shared'; -import { NotificationsState } from './notifications.state'; import { DateFnsModule } from '@ngx-starter-kit/ngx-utils'; +import { NotificationsState } from './notifications.state'; +import { NotificationsHandler } from './notifications.handler'; @NgModule({ imports: [SharedModule, DateFnsModule, NgxsModule.forFeature([NotificationsState])], declarations: [NotificationsComponent], exports: [NotificationsComponent], }) -export class NotificationsModule {} +export class NotificationsModule { + // HINT: NotificationsHandler is injected here to initialize it as Module Run Block + constructor(notificationsHandler: NotificationsHandler) {} +} diff --git a/libs/notifications/src/lib/notifications.service.ts b/libs/notifications/src/lib/notifications.service.ts index ee32b5c55..a829989ed 100644 --- a/libs/notifications/src/lib/notifications.service.ts +++ b/libs/notifications/src/lib/notifications.service.ts @@ -20,7 +20,7 @@ export class NotificationsService extends EntityService { getAll(): Observable { this.loadingSubject.next(true); - return this.httpClient.get<[AppNotification[], number]>(`${this.baseUrl}/${this.entityPath}`).pipe( + return this.httpClient.get<[AppNotification[], number]>(`${this.baseUrl}/${this.entityPath}/user`).pipe( retry(3), // retry a failed request up to 3 times catchError(this.handleError), finalize(() => this.loadingSubject.next(false)), diff --git a/libs/notifications/src/lib/notifications.state.ts b/libs/notifications/src/lib/notifications.state.ts index 0f99fc48c..81d31922a 100644 --- a/libs/notifications/src/lib/notifications.state.ts +++ b/libs/notifications/src/lib/notifications.state.ts @@ -34,15 +34,6 @@ export class NotificationsState implements NgxsOnInit { @Action(AddNotification) add({ getState, setState, patchState }: StateContext, { payload }: AddNotification) { setState([...getState(), payload]); - if (payload.native) { - return this.notificationsService.showNativeNotification({ - title: 'NGX WebApp Notification', - options: { - body: payload.message, - icon: 'assets/icons/icon-72x72.png', - }, - }); - } } @Action(FetchNotifications, { cancelUncompleted: true }) diff --git a/package-lock.json b/package-lock.json index 3c85618f8..82ad8065d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,26 +5,26 @@ "requires": true, "dependencies": { "@angular-devkit/architect": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.11.0.tgz", - "integrity": "sha512-HiMrXZ6pj4OUHmDKnLj+CIzZmr92aklBvi20QBmHv6h82l/pSs9VG5R90Dr6zHZ04cKQgKaDFJTxNQld+hHUpw==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.11.1.tgz", + "integrity": "sha512-MdcZ5KclwL2SBXCQSn8uI2hakBX58EyuAwFWsM/pKrNt9j8RqIk93l4amd2OkaMtZRFP5zWodyf/3qOwacjuQg==", "dev": true, "requires": { - "@angular-devkit/core": "7.1.0", + "@angular-devkit/core": "7.1.1", "rxjs": "6.3.3" } }, "@angular-devkit/build-angular": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-0.11.0.tgz", - "integrity": "sha512-KNbVqApCfLNw7qG0i7nbirisePzcUBmqb5YJGMOEoB1pWASwGke2H/3NMTyJEDHtGDg5DdWuVvyoyXaGEKMoJg==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-0.11.1.tgz", + "integrity": "sha512-hA/3GVMmRwOPXWhImrBG9gZTdERr937NMuedKhTXuNj6TNMNjk9XQ+q2erd0LZVbgfhL/nC0wHnpy0dUWXu8jA==", "dev": true, "requires": { - "@angular-devkit/architect": "0.11.0", - "@angular-devkit/build-optimizer": "0.11.0", - "@angular-devkit/build-webpack": "0.11.0", - "@angular-devkit/core": "7.1.0", - "@ngtools/webpack": "7.1.0", + "@angular-devkit/architect": "0.11.1", + "@angular-devkit/build-optimizer": "0.11.1", + "@angular-devkit/build-webpack": "0.11.1", + "@angular-devkit/core": "7.1.1", + "@ngtools/webpack": "7.1.1", "ajv": "6.5.3", "autoprefixer": "9.3.1", "circular-dependency-plugin": "5.0.2", @@ -69,12 +69,6 @@ "webpack-subresource-integrity": "1.1.0-rc.6" }, "dependencies": { - "parse5": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-4.0.0.tgz", - "integrity": "sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==", - "dev": true - }, "semver": { "version": "5.5.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.1.tgz", @@ -84,13 +78,13 @@ } }, "@angular-devkit/build-ng-packagr": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-ng-packagr/-/build-ng-packagr-0.11.0.tgz", - "integrity": "sha512-+VO9LOFABIWEgtN92TkEiTwId++u1C+nBOHAbE+6ukfWeCnoBTHQoMX+HM92aklosSio53EuzRWiSkQphxbTcQ==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-ng-packagr/-/build-ng-packagr-0.11.1.tgz", + "integrity": "sha512-NIw2eqp0vPOOn8ajXvlBMWi4ckoV3Vbq4cc6dquugKP/hA4/hUsRNy9XHUxsEKZjHmccFLlqz4nkAMFu+Hy2XA==", "dev": true, "requires": { - "@angular-devkit/architect": "0.11.0", - "@angular-devkit/core": "7.1.0", + "@angular-devkit/architect": "0.11.1", + "@angular-devkit/core": "7.1.1", "rxjs": "6.3.3", "semver": "5.5.1" }, @@ -104,9 +98,9 @@ } }, "@angular-devkit/build-optimizer": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-optimizer/-/build-optimizer-0.11.0.tgz", - "integrity": "sha512-a7nIw6bN/kO77NnWoLzuoEep8jVSDxDyXZZMjvv2+bdcnua1rsScuJKII5PjGIjIucLNUJRwdHQFovVDXRMCPQ==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-optimizer/-/build-optimizer-0.11.1.tgz", + "integrity": "sha512-pyFP6ykZf8Iq8nRkgP2XKq8knpIG6ye0qYklnBC9815AC5RAO126Y4fmtd6tnH+5p1mQxnt5HegG0j5xOCgDRw==", "dev": true, "requires": { "loader-utils": "1.1.0", @@ -142,20 +136,20 @@ } }, "@angular-devkit/build-webpack": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.11.0.tgz", - "integrity": "sha512-AGkpHv9k9pjVEe1IihtHBWpYPSBYDEui5tFaXE6zEuXl8EbPRVW6fP4SpfEOefgCRwrUe3VP9+q5IlGgKVOXlg==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.11.1.tgz", + "integrity": "sha512-p7fPHOi2Wfq2VPtnRVowg3n99MujghpOp6zW0gBJQD1TQhGVzPK6AX42S0NA4d05ahNBCDU2n7Y+5TjNJRIGJw==", "dev": true, "requires": { - "@angular-devkit/architect": "0.11.0", - "@angular-devkit/core": "7.1.0", + "@angular-devkit/architect": "0.11.1", + "@angular-devkit/core": "7.1.1", "rxjs": "6.3.3" } }, "@angular-devkit/core": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-7.1.0.tgz", - "integrity": "sha512-mR0YNRBEWfK3y5JfPmENw6Qy8kk6jaJTjDOso1uOwRKWQDe642tnK0P1HTmZ+WBgp+RhYD4pHbKePqOHw/tsdQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-7.1.1.tgz", + "integrity": "sha512-rODqECpOiV6vX+L1qd63GLiF3SG+V1O+d8WYtnKPOxnsMM9yWpWmqmroHtXfisjucu/zwoqj8HoO/noJZCfynw==", "requires": { "ajv": "6.5.3", "chokidar": "2.0.4", @@ -165,11 +159,11 @@ } }, "@angular-devkit/schematics": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-7.1.0.tgz", - "integrity": "sha512-MIK6eT3x6EppUcz7KFwJ63z3gUVmi5dQPiN8p+kTpHE2SorZCQvQ6+YKUMw9VZ6WLEQOZYJfoQozKyEWllNlsw==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-7.1.1.tgz", + "integrity": "sha512-yjzTw8ZWMPg0Fc9VQCHNpUCAH7aiNxrUDs0IbhdC0CyKTBoqH+cx2xP4Z6ECf4uNwceLKJlE0l3ot42Ypnlziw==", "requires": { - "@angular-devkit/core": "7.1.0", + "@angular-devkit/core": "7.1.1", "rxjs": "6.3.3" } }, @@ -182,25 +176,33 @@ } }, "@angular/cdk": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-7.1.0.tgz", - "integrity": "sha512-dY740pKcIRtKr6n6NomrgqfdEj988urTZ9I/bfJjxF5fdhnSjyhEvDlB55EHsrF+bTTZbZXRmv7AwOQ9GJnD9w==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-7.1.1.tgz", + "integrity": "sha512-woW9lWDBKRuxZipMzWofrAY7YpuZd4vf/J1YPjmAqV7U94MaDFyizRLyFolbTZVYo8ggh9U3SQAWRrEBvJNsjg==", "requires": { "parse5": "^5.0.0", "tslib": "^1.7.1" + }, + "dependencies": { + "parse5": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.0.tgz", + "integrity": "sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ==", + "optional": true + } } }, "@angular/cli": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-7.1.0.tgz", - "integrity": "sha512-G7WZvClrZjfo0VL6eFxwzqPffUQr3XbdkdCUcVbzJVnkFLrBG5Q2jFOJaZ4uFeRW4z5UM+8u/4N9N1Z6MH2QAQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-7.1.1.tgz", + "integrity": "sha512-lPVKsk035T5Ls0Mf83OngrNoLZu/ucZSjRLN/GWZK1O/YYVmb/dTgVl/a7HC+G480tWQ34nlqnCRbrP7sE9v7g==", "dev": true, "requires": { - "@angular-devkit/architect": "0.11.0", - "@angular-devkit/core": "7.1.0", - "@angular-devkit/schematics": "7.1.0", - "@schematics/angular": "7.1.0", - "@schematics/update": "0.11.0", + "@angular-devkit/architect": "0.11.1", + "@angular-devkit/core": "7.1.1", + "@angular-devkit/schematics": "7.1.1", + "@schematics/angular": "7.1.1", + "@schematics/update": "0.11.1", "inquirer": "6.2.0", "opn": "5.3.0", "semver": "5.5.1", @@ -552,9 +554,9 @@ "dev": true }, "@angular/material": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@angular/material/-/material-7.1.0.tgz", - "integrity": "sha512-bgotNpSfGLjNZ1AcTyhs6XS7trF4I7UHwQmfa0l8y3Gf9plwErPDfQe2XqnayRyG9nTwHj9f1lQ45X5mr3/0/A==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-7.1.1.tgz", + "integrity": "sha512-wjYAWsdWpb8/BgoIfoUomnycoljU00avJ3hRIgPNnEpZhB7zqiBA8tCitzDS4NK8dKBJjM9WRAOj6yl6x3+9wA==", "requires": { "tslib": "^1.7.1" } @@ -576,13 +578,13 @@ } }, "@angular/pwa": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@angular/pwa/-/pwa-0.11.0.tgz", - "integrity": "sha512-j4EBejByolKEfZLUfO5qloAV+DKVNDSq8Arxt3xEsomAm7NJFN83+w7AcgkG6eQVRuEQtBUaa7blPWEGOO9m1w==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@angular/pwa/-/pwa-0.11.1.tgz", + "integrity": "sha512-KR/IrMvDWBJXTryezIAtRoedjPWfv4MGMOCl5W6Gcb9fJb/Irtmly247wFlvucmaSkLYJHhXDowBgCCluq5qhw==", "requires": { - "@angular-devkit/core": "7.1.0", - "@angular-devkit/schematics": "7.1.0", - "@schematics/angular": "7.1.0", + "@angular-devkit/core": "7.1.1", + "@angular-devkit/schematics": "7.1.1", + "@schematics/angular": "7.1.1", "parse5-html-rewriting-stream": "5.1.0", "rxjs": "6.3.3" } @@ -1494,12 +1496,12 @@ "dev": true }, "@ngtools/webpack": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-7.1.0.tgz", - "integrity": "sha512-U4+2fPEEdvQN6/SmdlNYiuuwWbSEUP3Rpfbkzj7hYOHMQHnWA90u6EfZLjoyE77qqhF3EGXszjmZnYls78/c7Q==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-7.1.1.tgz", + "integrity": "sha512-XW/YDjiDZlwOYK4YvGAIKIVEkqtdwPLwTWAmDbnfpEHQc8UALsBrzGdjze0jSfXQdQxkbmXo0aolZgNc7uL/wQ==", "dev": true, "requires": { - "@angular-devkit/core": "7.1.0", + "@angular-devkit/core": "7.1.1", "enhanced-resolve": "4.1.0", "rxjs": "6.3.3", "tree-kill": "1.2.0", @@ -1525,17 +1527,17 @@ } }, "@ngx-formly/core": { - "version": "5.0.0-beta.17", - "resolved": "https://registry.npmjs.org/@ngx-formly/core/-/core-5.0.0-beta.17.tgz", - "integrity": "sha512-Gfa0By3JIZG2zhe0XWCedz6P0spxIdkFEUiE93O3Z+KIz8dNDR8b2kSOBDpogQOgKAvmS1oqtFWktoU0SumMHw==", + "version": "5.0.0-beta.18", + "resolved": "https://registry.npmjs.org/@ngx-formly/core/-/core-5.0.0-beta.18.tgz", + "integrity": "sha512-aMLU4SCI7eU0o6QuH2v+MsYPZWBWmhyd8fYlKUuAHl83KBBdc3XcJlEjbuuX9KGCUXzRmR8HCSn5nb96gN1fdw==", "requires": { "tslib": "^1.7.1" } }, "@ngx-formly/material": { - "version": "5.0.0-beta.17", - "resolved": "https://registry.npmjs.org/@ngx-formly/material/-/material-5.0.0-beta.17.tgz", - "integrity": "sha512-lfJGg2pjybYg0dsAWv8Iw/mWbF9lQ7Wew9msbgSWZUEtdkNgYts5tz0rJQZ7VY3F2bMZy+J9BQKcF2DZ34s9gA==", + "version": "5.0.0-beta.18", + "resolved": "https://registry.npmjs.org/@ngx-formly/material/-/material-5.0.0-beta.18.tgz", + "integrity": "sha512-Wjf58MJWbRv3ejjtpK48CTAcjaEgshbWr0YLq2Pv7PsB60C5VBCbNplPThpnN08ufyLBMpn8GJVToh0taKOZMw==", "requires": { "tslib": "^1.9.0" } @@ -2290,23 +2292,23 @@ } }, "@schematics/angular": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-7.1.0.tgz", - "integrity": "sha512-BLRTHlhYXgP49OwDyoolwolf7LqxOAPuc8lpgH0HEmYjkXmufZ4urngyFKY1IuBwaAR4PLjDx3U/ofszyV0taw==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-7.1.1.tgz", + "integrity": "sha512-jMaj8y3rNTQQXuH38uoWfAOmwYjtzqo1RelNfACnT54mfO/Dat+k7WasBLHWuvzvnN4/Ga3kXL7sJpkeMciiIg==", "requires": { - "@angular-devkit/core": "7.1.0", - "@angular-devkit/schematics": "7.1.0", + "@angular-devkit/core": "7.1.1", + "@angular-devkit/schematics": "7.1.1", "typescript": "3.1.6" } }, "@schematics/update": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@schematics/update/-/update-0.11.0.tgz", - "integrity": "sha512-Zrt4MQOM8DjK7fYVrzx08KhQ7jSj/at0/uF+Ca+ObZJIiC67IY8NXlc1TETXpB4A2UYrclvc9mTpZrvgIoEcYA==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@schematics/update/-/update-0.11.1.tgz", + "integrity": "sha512-IzPXamoMpDb2eY2zSW4fPuuH+7RfJLte9XVzQM2y3ZTBhlJQFLqx7qJtOXdcXUboonC6o61KCayNDERFnDUdPg==", "dev": true, "requires": { - "@angular-devkit/core": "7.1.0", - "@angular-devkit/schematics": "7.1.0", + "@angular-devkit/core": "7.1.1", + "@angular-devkit/schematics": "7.1.1", "@yarnpkg/lockfile": "1.1.0", "ini": "1.3.5", "pacote": "9.1.1", @@ -3141,9 +3143,9 @@ "integrity": "sha512-A2TAGbTFdBw9azHbpVd+/FkdW2T6msN1uct1O9bH3vTerEHKZhTXJUQXy+hNq1B0RagfU8U+KBdqiZpxjhOUQA==" }, "@types/node": { - "version": "10.12.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.11.tgz", - "integrity": "sha512-3iIOhNiPGTdcUNVCv9e5G7GotfvJJe2pc9w2UgDXlUwnxSZ3RgcUocIU+xYm+rTU54jIKih998QE4dMOyMN1NQ==" + "version": "10.12.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.12.tgz", + "integrity": "sha512-Pr+6JRiKkfsFvmU/LK68oBRCQeEg36TyAbPhc2xpez24OOZZCuoIhWGTd39VZy6nGafSbxzGouFPTFD/rR1A0A==" }, "@types/nodemailer": { "version": "4.6.5", @@ -5248,9 +5250,9 @@ }, "dependencies": { "array-flatten": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.1.tgz", - "integrity": "sha1-Qmu52oQJDBg42BLIFQryCoMx4pY=", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", + "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==", "dev": true } } @@ -9606,9 +9608,9 @@ "dev": true }, "filepond": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/filepond/-/filepond-3.4.0.tgz", - "integrity": "sha512-mHrXFW8H5c0QAkcMsbUSusyXX/eQ/8nOFHQuxQ9WtjSOSh0LZYJqtpR6JiUsG2fAcp4jMFDIBj9PJGsufEP7UA==" + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/filepond/-/filepond-3.5.0.tgz", + "integrity": "sha512-Kd6+kUz3L0Oj5RqbS1JkHVa6Uh7n3IYwq88W9b8yg2OdXfBItJwSEbcjFI2ujeaKMu5zwHkuKpYaCZ8ZHkD5Fg==" }, "filepond-plugin-file-encode": { "version": "2.0.0", @@ -20684,9 +20686,10 @@ "dev": true }, "parse5": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.0.tgz", - "integrity": "sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ==" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-4.0.0.tgz", + "integrity": "sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==", + "dev": true }, "parse5-html-rewriting-stream": { "version": "5.1.0", @@ -20695,6 +20698,13 @@ "requires": { "parse5": "^5.1.0", "parse5-sax-parser": "^5.1.0" + }, + "dependencies": { + "parse5": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.0.tgz", + "integrity": "sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ==" + } } }, "parse5-sax-parser": { @@ -20703,6 +20713,13 @@ "integrity": "sha512-VEhdEDhBkoSILPmsZ96SoIIUow3hZbtgQsqXw7r8DxxnqsCIO0fwkT9mWgBcf9SPjVUh92liuEprHrrYzXBPWQ==", "requires": { "parse5": "^5.1.0" + }, + "dependencies": { + "parse5": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.0.tgz", + "integrity": "sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ==" + } } }, "parseqs": { @@ -22602,9 +22619,9 @@ } }, "semantic-release": { - "version": "15.12.3", - "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-15.12.3.tgz", - "integrity": "sha512-3HCKD2ORxi6jItIoP9WeYJjjHsZqozSf6WUGcLClnRH553OVNKf4mLdOwo9UHJe6dVNSk5z7oDfGWKGwVy63BA==", + "version": "15.12.4", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-15.12.4.tgz", + "integrity": "sha512-po30Te9E26v3Qb/G9pXFO6lCTFO07zvliqH00vmfuCoAjl1Wpg9SKb9dXVFM7BTdg5Fr/KqbdOsqHkT7kR4FeQ==", "dev": true, "requires": { "@semantic-release/commit-analyzer": "^6.1.0", @@ -24175,9 +24192,9 @@ } }, "terser": { - "version": "3.10.13", - "resolved": "https://registry.npmjs.org/terser/-/terser-3.10.13.tgz", - "integrity": "sha512-AgdHqw2leuADuHiP4Kkk1i40m10RMGguPaiCw6MVD6jtDR7N94zohGqAS2lkDXIS7eIkGit3ief3eQGh/Md+GQ==", + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-3.11.0.tgz", + "integrity": "sha512-5iLMdhEPIq3zFWskpmbzmKwMQixKmTYwY3Ox9pjtSklBLnHiuQ0GKJLhL1HSYtyffHM3/lDIFBnb82m9D7ewwQ==", "dev": true, "requires": { "commander": "~2.17.1", diff --git a/package.json b/package.json index 3544d3422..22223b0a9 100644 --- a/package.json +++ b/package.json @@ -135,8 +135,8 @@ "@nestjs/swagger": "^2.5.1", "@nestjs/typeorm": "^5.2.2", "@nestjs/websockets": "^5.4.0", - "@ngx-formly/core": "^5.0.0-beta.17", - "@ngx-formly/material": "^5.0.0-beta.17", + "@ngx-formly/core": "^5.0.0-beta.18", + "@ngx-formly/material": "^5.0.0-beta.18", "@ngx-lite/in-viewport": "^0.1.3", "@ngx-lite/input-star-rating": "^0.2.5", "@ngx-lite/input-tag": "^0.2.8", @@ -159,7 +159,7 @@ "d3": "4.13.0", "d3-selection-multi": "1.0.1", "date-fns": "^2.0.0-alpha.25", - "filepond": "^3.4.0", + "filepond": "^3.5.0", "filepond-plugin-file-encode": "^2.0.0", "filepond-plugin-file-validate-size": "^2.0.0", "filepond-plugin-file-validate-type": "^1.2.0", @@ -247,7 +247,7 @@ "tslib": "^1.9.3", "tslint": "^5.11.0", "tslint-config-prettier": "^1.17.0", - "typescript": "^3.1.6", + "typescript": "~3.1.6", "webpack-bundle-analyzer": "^3.0.3", "webpack-cli": "^3.1.2", "webpack-node-externals": "^1.7.2" diff --git a/stories/howto.md b/stories/howto.md index be290fe92..5349578a1 100644 --- a/stories/howto.md +++ b/stories/howto.md @@ -50,6 +50,10 @@ How to migrate project to newer versions? > [refer](https://update.angular.io/) +how to implement MODULE_INITIALIZER like APP_INITIALIZER? + +> [refer](https://www.bennadel.com/blog/3180-ngmodule-constructors-provide-a-module-level-run-block-in-angular-2-1-1.htm) + How to commit code? ```bash