From 945034c00c1f3b8c861b809b3f7b5027d3d92968 Mon Sep 17 00:00:00 2001 From: xmlking Date: Mon, 19 Nov 2018 20:12:29 -0800 Subject: [PATCH] feat(api): added push API module to save PushSubscriptions --- PLAYBOOK-NEST.md | 6 +++ apps/api/src/app.module.ts | 3 ++ apps/api/src/core/core.module.ts | 3 +- apps/api/src/core/crud/crud.controller.ts | 6 +-- .../src/core/entities/audit-base.entity.ts | 2 + .../dto/create-notification.dto.ts | 4 +- .../src/notifications/notification.entity.ts | 8 +-- .../src/push/dto/create-subscription.dto.ts | 23 ++++++++ apps/api/src/push/index.ts | 1 + apps/api/src/push/push.controller.spec.ts | 16 ++++++ apps/api/src/push/push.controller.ts | 31 +++++++++++ apps/api/src/push/push.module.ts | 12 +++++ apps/api/src/push/push.service.spec.ts | 16 ++++++ apps/api/src/push/push.service.ts | 12 +++++ apps/api/src/push/subscription.entity.ts | 52 +++++++++++++++++++ .../lib/services/push-notification.service.ts | 33 ++++++++---- .../settings/settings.component.html | 2 +- .../containers/settings/settings.component.ts | 6 +-- package-lock.json | 6 +-- package.api.json | 2 +- package.json | 2 +- 21 files changed, 217 insertions(+), 29 deletions(-) create mode 100644 apps/api/src/push/dto/create-subscription.dto.ts create mode 100644 apps/api/src/push/index.ts create mode 100644 apps/api/src/push/push.controller.spec.ts create mode 100644 apps/api/src/push/push.controller.ts create mode 100644 apps/api/src/push/push.module.ts create mode 100644 apps/api/src/push/push.service.spec.ts create mode 100644 apps/api/src/push/push.service.ts create mode 100644 apps/api/src/push/subscription.entity.ts diff --git a/PLAYBOOK-NEST.md b/PLAYBOOK-NEST.md index fa763e5b1..93fd4d044 100644 --- a/PLAYBOOK-NEST.md +++ b/PLAYBOOK-NEST.md @@ -101,6 +101,12 @@ 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 ``` diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 891ceac4d..3f9520503 100755 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -7,6 +7,7 @@ import { UserModule } from './user'; // import { ChatModule } from './chat'; import { AppController } from './app.controller'; import { NotificationsModule } from './notifications'; +import { PushModule } from './push'; @Module({ imports: [ @@ -16,6 +17,7 @@ import { NotificationsModule } from './notifications'; children: [ { path: '/auth', module: AuthModule }, { path: '/user', module: UserModule }, + { path: '/push', module: PushModule }, // { path: '/account', module: AccountModule }, { path: '/notifications', module: NotificationsModule }, ], @@ -26,6 +28,7 @@ import { NotificationsModule } from './notifications'; UserModule, // AccountModule, NotificationsModule, + PushModule, // ChatModule, ], controllers: [AppController], diff --git a/apps/api/src/core/core.module.ts b/apps/api/src/core/core.module.ts index dcfaae2ff..a89b94beb 100644 --- a/apps/api/src/core/core.module.ts +++ b/apps/api/src/core/core.module.ts @@ -10,6 +10,7 @@ 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'; @Module({ imports: [ @@ -19,7 +20,7 @@ import { environment as env } from '@env-api/environment'; imports: [ConfigModule], useFactory: async (config: ConfigService) => ({ ...env.database, - entities: [Notification, User], + entities: [Notification, User, Subscription], } as ConnectionOptions), inject: [ConfigService], }), diff --git a/apps/api/src/core/crud/crud.controller.ts b/apps/api/src/core/crud/crud.controller.ts index 36eca7ebb..6cd0c99f4 100644 --- a/apps/api/src/core/crud/crud.controller.ts +++ b/apps/api/src/core/crud/crud.controller.ts @@ -31,7 +31,7 @@ export abstract class CrudController { description: 'Invalid input, The response body may contain clues as to what went wrong', }) @Post() - async create(@Body() entity: DeepPartial): Promise { + async create(@Body() entity: DeepPartial, options?: any): Promise { return this.crudService.create(entity); } @@ -43,7 +43,7 @@ export abstract class CrudController { description: 'Invalid input, The response body may contain clues as to what went wrong', }) @Put(':id') - async update(@Param('id') id: string, @Body() entity: DeepPartial): Promise { + async update(@Param('id') id: string, @Body() entity: DeepPartial, options?: any): Promise { return this.crudService.update(id, entity as any); // FIXME: https://github.com/typeorm/typeorm/issues/1544 } @@ -51,7 +51,7 @@ export abstract class CrudController { @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): Promise { + async delete(@Param('id') id: string, options?: any): Promise { return this.crudService.delete(id); } } diff --git a/apps/api/src/core/entities/audit-base.entity.ts b/apps/api/src/core/entities/audit-base.entity.ts index bf83f2a35..df34b85c4 100644 --- a/apps/api/src/core/entities/audit-base.entity.ts +++ b/apps/api/src/core/entities/audit-base.entity.ts @@ -12,10 +12,12 @@ export abstract class AuditBase { @PrimaryGeneratedColumn() id: number; + @ApiModelProperty({ type: Date }) // @Exclude() @CreateDateColumn() createdAt?: Date; + @ApiModelProperty({ type: Date }) // @Exclude() @UpdateDateColumn() updatedAt?: Date; diff --git a/apps/api/src/notifications/dto/create-notification.dto.ts b/apps/api/src/notifications/dto/create-notification.dto.ts index 90da0a6a5..22d0d6b3d 100644 --- a/apps/api/src/notifications/dto/create-notification.dto.ts +++ b/apps/api/src/notifications/dto/create-notification.dto.ts @@ -3,7 +3,7 @@ import { IsAscii, IsBoolean, IsEnum, IsIn, IsNotEmpty, IsString, MaxLength, MinL import { NotificationColor, NotificationIcon } from '../notification.entity'; export class CreateNotificationDto { - @ApiModelProperty({ type: String, enum: NotificationIcon, default: 'notifications' }) + @ApiModelProperty({ type: String, enum: NotificationIcon, default: NotificationIcon.notifications }) @IsString() @IsNotEmpty() @IsEnum(NotificationIcon) @@ -19,7 +19,7 @@ export class CreateNotificationDto { @IsNotEmpty() read: boolean = false; - @ApiModelProperty({ type: String, enum: NotificationColor }) + @ApiModelProperty({ type: String, enum: NotificationColor, default: NotificationColor.PRIMARY }) @IsString() @IsNotEmpty() @IsIn(['warn', 'accent', 'primary']) diff --git a/apps/api/src/notifications/notification.entity.ts b/apps/api/src/notifications/notification.entity.ts index e6dceb34b..02920b503 100644 --- a/apps/api/src/notifications/notification.entity.ts +++ b/apps/api/src/notifications/notification.entity.ts @@ -20,7 +20,7 @@ export enum NotificationIcon { @Entity('notification') export class Notification extends Base { - @ApiModelProperty({ type: String }) + @ApiModelProperty({ type: String, enum: NotificationIcon, default: NotificationIcon.notifications }) @IsString() @IsNotEmpty() @Column() @@ -43,10 +43,10 @@ export class Notification extends Base { @Column() read: boolean; - @ApiModelProperty({ type: String }) + @ApiModelProperty({ type: String, enum: NotificationColor, default: NotificationColor.PRIMARY }) @IsString() @IsNotEmpty() - @Column() + @Column({ enum: ['warn', 'accent', 'primary'] }) color?: NotificationColor; @ApiModelProperty({ type: String, minLength: 8, maxLength: 20 }) @@ -62,6 +62,6 @@ export class Notification extends Base { @IsBoolean() @IsNotEmpty() @Index() - @Column({ default: false}) + @Column({ default: false }) native: boolean; } diff --git a/apps/api/src/push/dto/create-subscription.dto.ts b/apps/api/src/push/dto/create-subscription.dto.ts new file mode 100644 index 000000000..4115eff1b --- /dev/null +++ b/apps/api/src/push/dto/create-subscription.dto.ts @@ -0,0 +1,23 @@ +import { ApiModelProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, IsUrl } from 'class-validator'; + +export class CreateSubscriptionDto { + @ApiModelProperty({ type: String }) + @IsNotEmpty() + @IsUrl({}, { message: 'endpoint must be a valid url.' }) + endpoint: string; + + @ApiModelProperty({ type: String }) + @IsNotEmpty() + @IsString() + auth: string; + + @ApiModelProperty({ type: String }) + @IsNotEmpty() + @IsString() + p256dh: string; + + @ApiModelProperty({ type: String, isArray: true }) + @IsNotEmpty() + topics: string[]; +} diff --git a/apps/api/src/push/index.ts b/apps/api/src/push/index.ts new file mode 100644 index 000000000..b69327291 --- /dev/null +++ b/apps/api/src/push/index.ts @@ -0,0 +1 @@ +export * from './push.module'; diff --git a/apps/api/src/push/push.controller.spec.ts b/apps/api/src/push/push.controller.spec.ts new file mode 100644 index 000000000..dfc8cd3f0 --- /dev/null +++ b/apps/api/src/push/push.controller.spec.ts @@ -0,0 +1,16 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PushController } from './push.controller'; + +describe('Push Controller', () => { + let module: TestingModule; + + beforeAll(async () => { + module = await Test.createTestingModule({ + controllers: [PushController], + }).compile(); + }); + it('should be defined', () => { + const controller: PushController = module.get(PushController); + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/api/src/push/push.controller.ts b/apps/api/src/push/push.controller.ts new file mode 100644 index 000000000..929700a4b --- /dev/null +++ b/apps/api/src/push/push.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, HttpStatus, Post } from '@nestjs/common'; +import { ApiOAuth2Auth, ApiOperation, ApiResponse, ApiUseTags } from '@nestjs/swagger'; +import { CrudController } from '../core'; +import { Subscription } from './subscription.entity'; +import { PushService } from './push.service'; +import { CreateSubscriptionDto } from './dto/create-subscription.dto'; +import { CurrentUser, User } from '../auth'; + +@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 { + return super.create({ ...entity, userId: user.userId }); + } +} diff --git a/apps/api/src/push/push.module.ts b/apps/api/src/push/push.module.ts new file mode 100644 index 000000000..a7197e735 --- /dev/null +++ b/apps/api/src/push/push.module.ts @@ -0,0 +1,12 @@ +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/push/push.service.spec.ts b/apps/api/src/push/push.service.spec.ts new file mode 100644 index 000000000..3888facc1 --- /dev/null +++ b/apps/api/src/push/push.service.spec.ts @@ -0,0 +1,16 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PushService } from './push.service'; + +describe('PushService', () => { + let service: PushService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [PushService], + }).compile(); + service = module.get(PushService); + }); + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/api/src/push/push.service.ts b/apps/api/src/push/push.service.ts new file mode 100644 index 000000000..3f902b523 --- /dev/null +++ b/apps/api/src/push/push.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { CrudService } from '../core'; +import { Repository } from 'typeorm'; +import { Subscription } from './subscription.entity'; + +@Injectable() +export class PushService extends CrudService { + constructor(@InjectRepository(Subscription) private readonly subscriptionRepository: Repository) { + super(subscriptionRepository); + } +} diff --git a/apps/api/src/push/subscription.entity.ts b/apps/api/src/push/subscription.entity.ts new file mode 100644 index 000000000..367288252 --- /dev/null +++ b/apps/api/src/push/subscription.entity.ts @@ -0,0 +1,52 @@ +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'; + +@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({}) + auth: string; + + @ApiModelProperty({ type: String }) + @IsNotEmpty() + @IsString() + @Column({}) + p256dh: string; + + @ApiModelProperty({ type: String, minLength: 8, maxLength: 20 }) + @IsAscii() + @IsNotEmpty() + @MinLength(8) + @MaxLength(20) + @Index() + @Column() + userId: string; + + @ApiModelProperty({ type: String, isArray: true }) + @Column('text', { array: true }) + topics: string[]; + + @ApiModelProperty({ type: Date }) + @CreateDateColumn() + createdAt?: Date; + + @ApiModelProperty({ type: Date }) + @UpdateDateColumn() + updatedAt?: Date; + + @Exclude() + @VersionColumn() + version?: number; +} diff --git a/libs/core/src/lib/services/push-notification.service.ts b/libs/core/src/lib/services/push-notification.service.ts index 0cf84c167..35cf05435 100644 --- a/libs/core/src/lib/services/push-notification.service.ts +++ b/libs/core/src/lib/services/push-notification.service.ts @@ -2,37 +2,50 @@ import { Injectable } from '@angular/core'; import { SwPush } from '@angular/service-worker'; import { from as fromPromise, Observable, of } from 'rxjs'; import { environment } from '@env/environment'; +import { take } from 'rxjs/operators'; // import {ApiService} from './api.service'; @Injectable({ providedIn: 'root', }) export class PushNotificationService { - private pushSubscription: PushSubscription; get available(): boolean { return this.swPush.isEnabled; } - constructor(private readonly swPush: SwPush /*private readonly apiService: ApiService*/) {} + constructor(private readonly swPush: SwPush /*private readonly apiService: ApiService*/) { + // subscribe for new messages for testing + this.swPush.messages.subscribe(message => { + console.log('received push notification', message); + }); + } async register() { - if (!this.swPush.isEnabled) { + if (!this.available) { return; } // Key generation: https://web-push-codelab.glitch.me const subscription = await this.swPush.requestSubscription({ serverPublicKey: environment.webPush.publicVapidKey }); - console.log('Push subscription', subscription); - this.pushSubscription = subscription; - // return this.apiService.post('push/register', subscription).subscribe(); + console.log('Push subscription created', subscription.toJSON()); + // this.pushSubscription = subscription; + // this.apiService.post('api/push', subscription).subscribe(); } - unregister(): Observable { - if (this.pushSubscription) { - return fromPromise(this.pushSubscription.unsubscribe()); + async unregister() { + if (!this.available) { + return; } - return of(true); + const subscription = await this.swPush.subscription.pipe(take(1)).toPromise(); + + if (subscription) { + console.log('deleting subscription', subscription.toJSON()); + // this.apiService.delete('api/push', subscription).subscribe(); + console.log('subscription deleted from database'); + const res = await subscription.unsubscribe(); + console.log('subscription deleted from local', res); + } } } diff --git a/libs/dashboard/src/lib/containers/settings/settings.component.html b/libs/dashboard/src/lib/containers/settings/settings.component.html index 110f587ce..6f7a623c0 100644 --- a/libs/dashboard/src/lib/containers/settings/settings.component.html +++ b/libs/dashboard/src/lib/containers/settings/settings.component.html @@ -33,7 +33,7 @@
-
Notifications
+
Push Notifications
{ + .subscribe(async enableNotifications => { if (enableNotifications) { - this.pnServ.register(); + await this.pnServ.register(); this.store.dispatch(new EnableNotifications()); } else { - this.pnServ.unregister(); + await this.pnServ.unregister(); this.store.dispatch(new DisableNotifications()); } }); diff --git a/package-lock.json b/package-lock.json index 6d1c406ec..9e32df48b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16458,9 +16458,9 @@ "dev": true }, "nodemailer": { - "version": "4.6.8", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-4.6.8.tgz", - "integrity": "sha512-A3s7EM/426OBIZbLHXq2KkgvmKbn2Xga4m4G+ZUA4IaZvG8PcZXrFh+2E4VaS2o+emhuUVRnzKN2YmpkXQ9qwA==" + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-4.7.0.tgz", + "integrity": "sha512-IludxDypFpYw4xpzKdMAozBSkzKHmNBvGanUREjJItgJ2NYcK/s8+PggVhj7c2yGFQykKsnnmv1+Aqo0ZfjHmw==" }, "nodemon": { "version": "1.18.6", diff --git a/package.api.json b/package.api.json index bf889575e..34429ff63 100644 --- a/package.api.json +++ b/package.api.json @@ -14,7 +14,7 @@ "cls-hooked": "^4.2.2", "helmet": "^3.15.0", "nest-router": "^1.0.7", - "nodemailer": "^4.6.8", + "nodemailer": "^4.7.0", "passport": "^0.4.0", "passport-jwt": "^4.0.0", "pg": "^7.6.1", diff --git a/package.json b/package.json index aa5564fcb..815779046 100644 --- a/package.json +++ b/package.json @@ -173,7 +173,7 @@ "ngx-moment": "^3.2.0", "ngx-page-scroll": "^5.0.0", "ngx-perfect-scrollbar": "^7.0.0", - "nodemailer": "^4.6.8", + "nodemailer": "^4.7.0", "passport": "^0.4.0", "passport-jwt": "^4.0.0", "pg": "^7.6.1",