From a033c50c2525532df83565357b13375ea21e2334 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Mon, 29 Jun 2026 14:52:33 +0200 Subject: [PATCH] pwa notifications --- listify-api/package-lock.json | 85 +++++++ listify-api/package.json | 2 + listify-api/src/auth/user.entity.ts | 7 + listify-api/src/database/data-source.ts | 2 + ...82100000000-CreateTaskPushSubscriptions.ts | 40 ++++ .../tasks/dto/task-push-subscription.dto.ts | 12 + .../src/tasks/task-digest.service.spec.ts | 28 +++ listify-api/src/tasks/task-digest.service.ts | 33 +-- .../tasks/task-push-subscription.entity.ts | 55 +++++ listify-api/src/tasks/task-push.service.ts | 211 ++++++++++++++++++ listify-api/src/tasks/tasks.controller.ts | 37 ++- listify-api/src/tasks/tasks.module.ts | 12 +- listify-client/public/sw.js | 50 ++++- .../src/app/account/account.component.html | 44 ++++ .../src/app/account/account.component.scss | 27 +++ .../src/app/account/account.component.ts | 35 +++ listify-client/src/app/app.routes.ts | 8 +- .../src/app/tasks/task-push.service.ts | 118 ++++++++++ 18 files changed, 780 insertions(+), 26 deletions(-) create mode 100644 listify-api/src/database/migrations/1782100000000-CreateTaskPushSubscriptions.ts create mode 100644 listify-api/src/tasks/dto/task-push-subscription.dto.ts create mode 100644 listify-api/src/tasks/task-push-subscription.entity.ts create mode 100644 listify-api/src/tasks/task-push.service.ts create mode 100644 listify-client/src/app/tasks/task-push.service.ts diff --git a/listify-api/package-lock.json b/listify-api/package-lock.json index d0f7d38..ff1f66b 100644 --- a/listify-api/package-lock.json +++ b/listify-api/package-lock.json @@ -18,6 +18,7 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/platform-express": "^11.0.1", "@nestjs/typeorm": "^11.0.1", + "@types/web-push": "^3.6.4", "handlebars": "^4.7.9", "helmet": "^8.2.0", "mysql2": "^3.22.5", @@ -26,6 +27,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^0.3.30", + "web-push": "^3.6.7", "zod": "^3.25.76" }, "devDependencies": { @@ -3443,6 +3445,15 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -4358,6 +4369,15 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -4589,6 +4609,18 @@ "devOptional": true, "license": "MIT" }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/assert-never": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.4.0.tgz", @@ -4790,6 +4822,12 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bn.js": { + "version": "4.12.4", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", + "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -7664,6 +7702,15 @@ "entities": "^4.5.0" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -7684,6 +7731,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -9762,6 +9822,12 @@ "node": ">=6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -14131,6 +14197,25 @@ "defaults": "^1.0.3" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/web-resource-inliner": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/web-resource-inliner/-/web-resource-inliner-8.0.0.tgz", diff --git a/listify-api/package.json b/listify-api/package.json index 276c608..3f8f0f2 100644 --- a/listify-api/package.json +++ b/listify-api/package.json @@ -33,6 +33,7 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/platform-express": "^11.0.1", "@nestjs/typeorm": "^11.0.1", + "@types/web-push": "^3.6.4", "handlebars": "^4.7.9", "helmet": "^8.2.0", "mysql2": "^3.22.5", @@ -41,6 +42,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^0.3.30", + "web-push": "^3.6.7", "zod": "^3.25.76" }, "devDependencies": { diff --git a/listify-api/src/auth/user.entity.ts b/listify-api/src/auth/user.entity.ts index bc1c589..9fa5235 100644 --- a/listify-api/src/auth/user.entity.ts +++ b/listify-api/src/auth/user.entity.ts @@ -11,6 +11,7 @@ import { ListTemplateEntity } from '../list-templates/list-template.entity'; import { ListTemplateShareEntity } from '../list-templates/list-template-share.entity'; import { UserListEntity } from '../lists/user-list.entity'; import { UserListShareEntity } from '../lists/user-list-share.entity'; +import { TaskPushSubscriptionEntity } from '../tasks/task-push-subscription.entity'; import { UserTaskEntity } from '../tasks/user-task.entity'; import type { TaskDigestPreference } from '../tasks/task-digest.types'; import { RefreshTokenEntity } from './refresh-token.entity'; @@ -86,6 +87,12 @@ export class UserEntity { @OneToMany(() => UserTaskEntity, (task) => task.owner) tasks?: UserTaskEntity[]; + @OneToMany( + () => TaskPushSubscriptionEntity, + (subscription) => subscription.user, + ) + taskPushSubscriptions?: TaskPushSubscriptionEntity[]; + @OneToMany(() => ListTemplateShareEntity, (share) => share.user) sharedTemplates?: ListTemplateShareEntity[]; } diff --git a/listify-api/src/database/data-source.ts b/listify-api/src/database/data-source.ts index 9bfa280..992484d 100644 --- a/listify-api/src/database/data-source.ts +++ b/listify-api/src/database/data-source.ts @@ -13,6 +13,7 @@ import { ListTemplateShareEntity } from '../list-templates/list-template-share.e import { TemplateSeedEntity } from '../list-templates/template-seed.entity'; import { UserListEntity } from '../lists/user-list.entity'; import { UserListItemEntity } from '../lists/user-list-item.entity'; +import { TaskPushSubscriptionEntity } from '../tasks/task-push-subscription.entity'; import { UserTaskEntity } from '../tasks/user-task.entity'; import { databaseLoggerOptionsFromEnv, @@ -42,6 +43,7 @@ export default new DataSource({ ListTemplateItemEntity, ListTemplateShareEntity, TemplateSeedEntity, + TaskPushSubscriptionEntity, UserListEntity, UserListItemEntity, UserTaskEntity, diff --git a/listify-api/src/database/migrations/1782100000000-CreateTaskPushSubscriptions.ts b/listify-api/src/database/migrations/1782100000000-CreateTaskPushSubscriptions.ts new file mode 100644 index 0000000..58c303c --- /dev/null +++ b/listify-api/src/database/migrations/1782100000000-CreateTaskPushSubscriptions.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateTaskPushSubscriptions1782100000000 implements MigrationInterface { + name = 'CreateTaskPushSubscriptions1782100000000'; + + public async up(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasTable('task_push_subscriptions'))) { + await queryRunner.query(` + CREATE TABLE \`task_push_subscriptions\` ( + \`id\` varchar(36) NOT NULL, + \`userId\` varchar(36) NOT NULL, + \`endpoint\` varchar(512) NOT NULL, + \`p256dh\` varchar(160) NOT NULL, + \`auth\` varchar(80) NOT NULL, + \`expiresAt\` datetime(3) NULL, + \`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + \`updatedAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + INDEX \`IDX_task_push_subscriptions_user_id\` (\`userId\`), + UNIQUE INDEX \`IDX_task_push_subscriptions_endpoint\` (\`endpoint\`), + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB + `); + await queryRunner.query(` + ALTER TABLE \`task_push_subscriptions\` + ADD CONSTRAINT \`FK_task_push_subscriptions_user_id\` + FOREIGN KEY (\`userId\`) REFERENCES \`users\`(\`id\`) + ON DELETE CASCADE ON UPDATE NO ACTION + `); + } + } + + public async down(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasTable('task_push_subscriptions')) { + await queryRunner.query( + 'ALTER TABLE `task_push_subscriptions` DROP FOREIGN KEY `FK_task_push_subscriptions_user_id`', + ); + await queryRunner.query('DROP TABLE `task_push_subscriptions`'); + } + } +} diff --git a/listify-api/src/tasks/dto/task-push-subscription.dto.ts b/listify-api/src/tasks/dto/task-push-subscription.dto.ts new file mode 100644 index 0000000..d8de6b9 --- /dev/null +++ b/listify-api/src/tasks/dto/task-push-subscription.dto.ts @@ -0,0 +1,12 @@ +export class SaveTaskPushSubscriptionDto { + endpoint?: string; + expirationTime?: number | null; + keys?: { + p256dh?: string; + auth?: string; + }; +} + +export class DeleteTaskPushSubscriptionDto { + endpoint?: string; +} diff --git a/listify-api/src/tasks/task-digest.service.spec.ts b/listify-api/src/tasks/task-digest.service.spec.ts index 4c708ed..ba0911b 100644 --- a/listify-api/src/tasks/task-digest.service.spec.ts +++ b/listify-api/src/tasks/task-digest.service.spec.ts @@ -3,12 +3,14 @@ import { UserEntity } from '../auth/user.entity'; import { MailService } from '../mail/mail.service'; import { InMemoryRepository } from '../testing/in-memory-repository'; import { TaskDigestService } from './task-digest.service'; +import { TaskPushService } from './task-push.service'; import { UserTaskEntity } from './user-task.entity'; describe('TaskDigestService', () => { let usersRepository: InMemoryRepository; let tasksRepository: InMemoryRepository; let mailService: Pick; + let taskPushService: Pick; let service: TaskDigestService; beforeEach(() => { @@ -17,6 +19,9 @@ describe('TaskDigestService', () => { mailService = { sendTaskDigestEmail: jest.fn().mockResolvedValue(undefined), }; + taskPushService = { + sendTaskDigest: jest.fn().mockResolvedValue(undefined), + }; service = new TaskDigestService( usersRepository as never, tasksRepository as never, @@ -25,6 +30,7 @@ describe('TaskDigestService', () => { TASK_DIGEST_TIMEZONE: 'Europe/Budapest', }), mailService as MailService, + taskPushService as TaskPushService, ); }); @@ -69,6 +75,25 @@ describe('TaskDigestService', () => { ], }, ); + expect(taskPushService.sendTaskDigest).toHaveBeenCalledWith('owner-1', { + slot: 'morning', + date: '2026-06-29', + tasksUrl: 'http://client.test/tasks', + todayTasks: [ + { + title: 'Heute', + notes: undefined, + dueDate: '2026-06-29', + }, + ], + overdueTasks: [ + { + title: 'Alt', + notes: undefined, + dueDate: '2026-06-28', + }, + ], + }); expect( (await usersRepository.findOne({ where: { id: 'owner-1' } })) ?.taskDigestMorningProcessedDate, @@ -82,6 +107,7 @@ describe('TaskDigestService', () => { await service.processDueDigests(new Date('2026-06-29T07:00:00.000Z')); expect(mailService.sendTaskDigestEmail).not.toHaveBeenCalled(); + expect(taskPushService.sendTaskDigest).not.toHaveBeenCalled(); expect( (await usersRepository.findOne({ where: { id: 'owner-1' } })) ?.taskDigestMorningProcessedDate, @@ -111,6 +137,7 @@ describe('TaskDigestService', () => { await service.processDueDigests(new Date('2026-06-29T13:00:00.000Z')); expect(mailService.sendTaskDigestEmail).toHaveBeenCalledTimes(1); + expect(taskPushService.sendTaskDigest).toHaveBeenCalledTimes(1); expect(mailService.sendTaskDigestEmail).toHaveBeenCalledWith( 'both@example.com', expect.objectContaining({ slot: 'afternoon' }), @@ -124,6 +151,7 @@ describe('TaskDigestService', () => { await service.processDueDigests(new Date('2026-06-29T08:30:00.000Z')); expect(mailService.sendTaskDigestEmail).not.toHaveBeenCalled(); + expect(taskPushService.sendTaskDigest).not.toHaveBeenCalled(); }); async function saveUser(overrides: Partial = {}) { diff --git a/listify-api/src/tasks/task-digest.service.ts b/listify-api/src/tasks/task-digest.service.ts index a947878..60767bf 100644 --- a/listify-api/src/tasks/task-digest.service.ts +++ b/listify-api/src/tasks/task-digest.service.ts @@ -9,6 +9,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { IsNull, LessThanOrEqual, Repository } from 'typeorm'; import { UserEntity } from '../auth/user.entity'; import { MailService } from '../mail/mail.service'; +import { TaskPushService } from './task-push.service'; import { UserTaskEntity } from './user-task.entity'; import type { TaskDigestSlot } from './task-digest.types'; @@ -25,6 +26,7 @@ export class TaskDigestService implements OnModuleInit, OnModuleDestroy { private readonly tasksRepository: Repository, private readonly configService: ConfigService, private readonly mailService: MailService, + private readonly taskPushService: TaskPushService, ) {} onModuleInit(): void { @@ -100,23 +102,28 @@ export class TaskDigestService implements OnModuleInit, OnModuleDestroy { const todayTasks = tasks.filter((task) => task.dueDate === dateKey); if (tasks.length > 0) { + const digestPayload = { + slot, + date: dateKey, + tasksUrl: this.tasksUrl(), + todayTasks: todayTasks.map((task) => ({ + title: task.title, + notes: task.notes ?? undefined, + dueDate: task.dueDate, + })), + overdueTasks: overdueTasks.map((task) => ({ + title: task.title, + notes: task.notes ?? undefined, + dueDate: task.dueDate, + })), + }; + try { await this.mailService.sendTaskDigestEmail(user.email, { + ...digestPayload, displayName: user.name ?? undefined, - slot, - date: dateKey, - tasksUrl: this.tasksUrl(), - todayTasks: todayTasks.map((task) => ({ - title: task.title, - notes: task.notes ?? undefined, - dueDate: task.dueDate, - })), - overdueTasks: overdueTasks.map((task) => ({ - title: task.title, - notes: task.notes ?? undefined, - dueDate: task.dueDate, - })), }); + await this.taskPushService.sendTaskDigest(user.id, digestPayload); } catch (error) { this.logger.error( `Task digest could not be sent for user ${user.id}.`, diff --git a/listify-api/src/tasks/task-push-subscription.entity.ts b/listify-api/src/tasks/task-push-subscription.entity.ts new file mode 100644 index 0000000..89a861f --- /dev/null +++ b/listify-api/src/tasks/task-push-subscription.entity.ts @@ -0,0 +1,55 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { UserEntity } from '../auth/user.entity'; + +@Entity('task_push_subscriptions') +export class TaskPushSubscriptionEntity { + @PrimaryColumn({ type: 'varchar', length: 36 }) + id!: string; + + @Index() + @Column({ type: 'varchar', length: 36 }) + userId!: string; + + @Index({ unique: true }) + @Column({ type: 'varchar', length: 512 }) + endpoint!: string; + + @Column({ type: 'varchar', length: 160 }) + p256dh!: string; + + @Column({ type: 'varchar', length: 80 }) + auth!: string; + + @Column({ type: 'datetime', precision: 3, nullable: true }) + expiresAt?: Date | null; + + @CreateDateColumn({ + type: 'datetime', + precision: 3, + default: () => 'CURRENT_TIMESTAMP(3)', + }) + createdAt!: Date; + + @UpdateDateColumn({ + type: 'datetime', + precision: 3, + default: () => 'CURRENT_TIMESTAMP(3)', + onUpdate: 'CURRENT_TIMESTAMP(3)', + }) + updatedAt!: Date; + + @ManyToOne(() => UserEntity, (user) => user.taskPushSubscriptions, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'userId' }) + user?: UserEntity; +} diff --git a/listify-api/src/tasks/task-push.service.ts b/listify-api/src/tasks/task-push.service.ts new file mode 100644 index 0000000..4781555 --- /dev/null +++ b/listify-api/src/tasks/task-push.service.ts @@ -0,0 +1,211 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { randomUUID } from 'crypto'; +import { Repository } from 'typeorm'; +import webPush, { PushSubscription } from 'web-push'; +import { TaskPushSubscriptionEntity } from './task-push-subscription.entity'; +import { SaveTaskPushSubscriptionDto } from './dto/task-push-subscription.dto'; +import type { TaskDigestEmailItem } from '../mail/mail.types'; +import type { TaskDigestSlot } from './task-digest.types'; + +interface TaskDigestPushPayload { + slot: TaskDigestSlot; + date: string; + tasksUrl: string; + todayTasks: TaskDigestEmailItem[]; + overdueTasks: TaskDigestEmailItem[]; +} + +@Injectable() +export class TaskPushService { + private readonly logger = new Logger(TaskPushService.name); + private configured = false; + + constructor( + @InjectRepository(TaskPushSubscriptionEntity) + private readonly subscriptionsRepository: Repository, + private readonly configService: ConfigService, + ) {} + + publicKey(): { enabled: boolean; publicKey?: string } { + const publicKey = this.configService.get('VAPID_PUBLIC_KEY'); + const privateKey = this.configService.get('VAPID_PRIVATE_KEY'); + + return publicKey && privateKey + ? { enabled: true, publicKey } + : { enabled: false }; + } + + async saveSubscription( + userId: string, + dto: SaveTaskPushSubscriptionDto, + ): Promise<{ message: string }> { + const endpoint = this.requireText( + dto.endpoint, + 'Push endpoint is required.', + ); + const p256dh = this.requireText(dto.keys?.p256dh, 'Push key is required.'); + const auth = this.requireText( + dto.keys?.auth, + 'Push auth secret is required.', + ); + const existingSubscription = await this.subscriptionsRepository.findOne({ + where: { endpoint }, + }); + const subscription = + existingSubscription ?? + this.subscriptionsRepository.create({ + id: randomUUID(), + endpoint, + }); + + subscription.userId = userId; + subscription.p256dh = p256dh; + subscription.auth = auth; + subscription.expiresAt = + typeof dto.expirationTime === 'number' + ? new Date(dto.expirationTime) + : null; + + await this.subscriptionsRepository.save(subscription); + + return { message: 'Push subscription saved.' }; + } + + async deleteSubscription( + userId: string, + endpoint?: string, + ): Promise<{ message: string }> { + if (!endpoint) { + await this.subscriptionsRepository.delete({ userId }); + return { message: 'Push subscriptions deleted.' }; + } + + await this.subscriptionsRepository.delete({ userId, endpoint }); + return { message: 'Push subscription deleted.' }; + } + + async sendTaskDigest( + userId: string, + payload: TaskDigestPushPayload, + ): Promise { + if (!this.configureWebPush()) { + this.logger.warn( + 'Task push digest skipped because VAPID keys are not configured.', + ); + return; + } + + const subscriptions = await this.subscriptionsRepository.find({ + where: { userId }, + }); + + await Promise.all( + subscriptions.map((subscription) => + this.sendToSubscription(subscription, payload), + ), + ); + } + + private async sendToSubscription( + subscription: TaskPushSubscriptionEntity, + payload: TaskDigestPushPayload, + ): Promise { + try { + await webPush.sendNotification( + this.toPushSubscription(subscription), + JSON.stringify(this.toNotificationPayload(payload)), + ); + } catch (error) { + const statusCode = + typeof error === 'object' && error !== null && 'statusCode' in error + ? (error as { statusCode?: unknown }).statusCode + : undefined; + + if (statusCode === 404 || statusCode === 410) { + await this.subscriptionsRepository.delete({ id: subscription.id }); + return; + } + + this.logger.error( + `Task push notification could not be sent to subscription ${subscription.id}.`, + error instanceof Error ? error.stack : undefined, + ); + } + } + + private toPushSubscription( + subscription: TaskPushSubscriptionEntity, + ): PushSubscription { + return { + endpoint: subscription.endpoint, + expirationTime: subscription.expiresAt?.getTime() ?? null, + keys: { + p256dh: subscription.p256dh, + auth: subscription.auth, + }, + }; + } + + private toNotificationPayload(payload: TaskDigestPushPayload): { + title: string; + body: string; + url: string; + tag: string; + } { + const todayCount = payload.todayTasks.length; + const overdueCount = payload.overdueTasks.length; + const taskCount = todayCount + overdueCount; + const title = + payload.slot === 'morning' + ? `Guten Morgen: ${taskCount} offene Tasks` + : `Nachmittags-Check: ${taskCount} offene Tasks`; + const parts = [ + todayCount > 0 ? `${todayCount} fuer heute` : null, + overdueCount > 0 ? `${overdueCount} ueberfaellig` : null, + ].filter((part): part is string => part !== null); + + return { + title, + body: parts.join(', '), + url: payload.tasksUrl, + tag: `task-digest-${payload.slot}-${payload.date}`, + }; + } + + private configureWebPush(): boolean { + if (this.configured) { + return true; + } + + const publicKey = this.configService.get('VAPID_PUBLIC_KEY'); + const privateKey = this.configService.get('VAPID_PRIVATE_KEY'); + + if (!publicKey || !privateKey) { + return false; + } + + webPush.setVapidDetails( + this.configService.get( + 'VAPID_SUBJECT', + 'mailto:no-reply@listify.local', + ), + publicKey, + privateKey, + ); + this.configured = true; + + return true; + } + + private requireText(value: string | undefined, message: string): string { + const normalizedValue = value?.trim(); + + if (!normalizedValue) { + throw new BadRequestException(message); + } + + return normalizedValue; + } +} diff --git a/listify-api/src/tasks/tasks.controller.ts b/listify-api/src/tasks/tasks.controller.ts index 79bce55..41592c7 100644 --- a/listify-api/src/tasks/tasks.controller.ts +++ b/listify-api/src/tasks/tasks.controller.ts @@ -13,14 +13,22 @@ import { } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CreateTaskDto } from './dto/create-task.dto'; +import { + DeleteTaskPushSubscriptionDto, + SaveTaskPushSubscriptionDto, +} from './dto/task-push-subscription.dto'; import { UpdateTaskDto } from './dto/update-task.dto'; +import { TaskPushService } from './task-push.service'; import { TasksService } from './tasks.service'; import type { AuthenticatedRequest } from '../auth/auth.types'; @Controller('tasks') @UseGuards(JwtAuthGuard) export class TasksController { - constructor(private readonly tasksService: TasksService) {} + constructor( + private readonly taskPushService: TaskPushService, + private readonly tasksService: TasksService, + ) {} @Post() createTask( @@ -38,6 +46,33 @@ export class TasksController { return this.tasksService.listTasks(this.requireUserId(request), date); } + @Get('push/public-key') + getPushPublicKey() { + return this.taskPushService.publicKey(); + } + + @Post('push/subscriptions') + savePushSubscription( + @Req() request: AuthenticatedRequest, + @Body() dto: SaveTaskPushSubscriptionDto, + ) { + return this.taskPushService.saveSubscription( + this.requireUserId(request), + dto, + ); + } + + @Delete('push/subscriptions') + deletePushSubscription( + @Req() request: AuthenticatedRequest, + @Body() dto: DeleteTaskPushSubscriptionDto, + ) { + return this.taskPushService.deleteSubscription( + this.requireUserId(request), + dto.endpoint, + ); + } + @Get(':taskId') getTask( @Req() request: AuthenticatedRequest, diff --git a/listify-api/src/tasks/tasks.module.ts b/listify-api/src/tasks/tasks.module.ts index 673cc0b..4d358a7 100644 --- a/listify-api/src/tasks/tasks.module.ts +++ b/listify-api/src/tasks/tasks.module.ts @@ -4,20 +4,24 @@ import { AuditModule } from '../audit/audit.module'; import { UserEntity } from '../auth/user.entity'; import { MailModule } from '../mail/mail.module'; import { TaskDigestService } from './task-digest.service'; +import { TaskPushSubscriptionEntity } from './task-push-subscription.entity'; +import { TaskPushService } from './task-push.service'; import { TasksController } from './tasks.controller'; import { TasksService } from './tasks.service'; import { UserTaskEntity } from './user-task.entity'; -import { AuthModule } from 'src/auth/auth.module'; @Module({ imports: [ AuditModule, MailModule, - TypeOrmModule.forFeature([UserEntity, UserTaskEntity]), - AuthModule + TypeOrmModule.forFeature([ + UserEntity, + TaskPushSubscriptionEntity, + UserTaskEntity, + ]), ], controllers: [TasksController], - providers: [TaskDigestService, TasksService], + providers: [TaskDigestService, TaskPushService, TasksService], exports: [TasksService], }) export class TasksModule {} diff --git a/listify-client/public/sw.js b/listify-client/public/sw.js index 4cb0722..a4d231d 100644 --- a/listify-client/public/sw.js +++ b/listify-client/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'listify-shell-v1'; +const CACHE_NAME = 'listify-shell-v2'; const SHELL_ASSETS = ['/', '/index.html', '/manifest.webmanifest', '/pwa-icon.svg']; self.addEventListener('install', (event) => { @@ -15,11 +15,7 @@ self.addEventListener('activate', (event) => { caches .keys() .then((keys) => - Promise.all( - keys - .filter((key) => key !== CACHE_NAME) - .map((key) => caches.delete(key)), - ), + Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))), ) .then(() => self.clients.claim()), ); @@ -63,3 +59,45 @@ self.addEventListener('fetch', (event) => { }), ); }); + +self.addEventListener('push', (event) => { + let payload = {}; + + try { + payload = event.data ? event.data.json() : {}; + } catch { + payload = {}; + } + + const title = payload.title || 'Listify Tasks'; + const options = { + body: payload.body || 'Du hast offene Tasks.', + tag: payload.tag || 'listify-task-digest', + icon: '/pwa-icon.svg', + badge: '/pwa-icon.svg', + data: { + url: payload.url || '/tasks', + }, + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + const targetUrl = event.notification.data?.url || '/tasks'; + const absoluteUrl = new URL(targetUrl, self.location.origin).href; + + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { + for (const client of clients) { + if (client.url === absoluteUrl && 'focus' in client) { + return client.focus(); + } + } + + return self.clients.openWindow(absoluteUrl); + }), + ); +}); diff --git a/listify-client/src/app/account/account.component.html b/listify-client/src/app/account/account.component.html index b58020e..c2a866c 100644 --- a/listify-client/src/app/account/account.component.html +++ b/listify-client/src/app/account/account.component.html @@ -54,6 +54,50 @@ Speichert... } + +
+
+

PWA-Benachrichtigungen

+

+ Gleiche Tasks und Zeiten wie die Task-Mail. Die App braucht dafuer eine + Browser-Erlaubnis. +

+
+ + @if (!taskPush.supported()) { + Nicht unterstuetzt + } @else if (taskPush.permission() === 'granted') { + + } @else { + + } +
diff --git a/listify-client/src/app/account/account.component.scss b/listify-client/src/app/account/account.component.scss index d3ba5a2..8f0a550 100644 --- a/listify-client/src/app/account/account.component.scss +++ b/listify-client/src/app/account/account.component.scss @@ -80,6 +80,33 @@ font-size: 0.9rem; } +.push-row { + display: grid; + gap: 0.7rem; + padding-top: 0.8rem; + border-top: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent); +} + +.push-row h3, +.push-row p { + margin: 0; +} + +.push-row h3 { + font-size: 0.95rem; + font-weight: 600; +} + +.push-row p, +.push-state { + color: var(--mat-sys-on-surface-variant); + line-height: 1.45; +} + +.push-row button { + justify-self: start; +} + mat-card-actions { flex-wrap: wrap; gap: 0.5rem; diff --git a/listify-client/src/app/account/account.component.ts b/listify-client/src/app/account/account.component.ts index 4d4fe26..c65d47a 100644 --- a/listify-client/src/app/account/account.component.ts +++ b/listify-client/src/app/account/account.component.ts @@ -11,6 +11,7 @@ import { getAuthErrorMessage } from '../auth/error-message'; import { AuthService } from '../auth/auth.service'; import { TaskDigestPreference } from '../auth/auth.models'; import { OnboardingService } from '../onboarding/onboarding.service'; +import { TaskPushService } from '../tasks/task-push.service'; @Component({ selector: 'app-account', @@ -28,6 +29,7 @@ import { OnboardingService } from '../onboarding/onboarding.service'; export class AccountComponent { protected readonly auth = inject(AuthService); protected readonly onboarding = inject(OnboardingService); + protected readonly taskPush = inject(TaskPushService); private readonly router = inject(Router); private readonly snackBar = inject(MatSnackBar); protected savingTaskDigestPreference = false; @@ -71,6 +73,9 @@ export class AccountComponent { this.snackBar.open('Task-Mail-Einstellung gespeichert.', 'OK', { duration: 3000, }); + if (preference === 'none') { + void this.taskPush.disableCurrentSubscription(); + } }, error: (error: unknown) => { this.snackBar.open(getAuthErrorMessage(error), 'OK', { @@ -80,6 +85,36 @@ export class AccountComponent { }); } + async enablePushNotifications(): Promise { + await this.taskPush.enable(); + + if (this.taskPush.errorMessage()) { + this.snackBar.open(this.taskPush.errorMessage()!, 'OK', { + duration: 5000, + }); + return; + } + + this.snackBar.open('PWA-Benachrichtigungen aktiviert.', 'OK', { + duration: 3000, + }); + } + + async disablePushNotifications(): Promise { + await this.taskPush.disableCurrentSubscription(); + + if (this.taskPush.errorMessage()) { + this.snackBar.open(this.taskPush.errorMessage()!, 'OK', { + duration: 5000, + }); + return; + } + + this.snackBar.open('PWA-Benachrichtigungen deaktiviert.', 'OK', { + duration: 3000, + }); + } + logout(): void { this.auth.logout(); void this.router.navigateByUrl('/login'); diff --git a/listify-client/src/app/app.routes.ts b/listify-client/src/app/app.routes.ts index d385a9d..0c72aec 100644 --- a/listify-client/src/app/app.routes.ts +++ b/listify-client/src/app/app.routes.ts @@ -3,7 +3,6 @@ import { authGuard } from './auth/auth.guard'; import { unauthGuard } from './auth/unauth.guard'; import { LoginComponent } from './auth/login/login.component'; import { RegisterComponent } from './auth/register/register.component'; -import { AccountComponent } from './account/account.component'; import { VerifyEmailComponent } from './auth/verify-email/verify-email.component'; import { ListDetailComponent } from './lists/list-detail/list-detail.component'; import { ListsComponent } from './lists/lists.component'; @@ -54,6 +53,11 @@ export const routes: Routes = [ ), canActivate: [authGuard], }, - { path: 'account', component: AccountComponent, canActivate: [authGuard] }, + { + path: 'account', + loadComponent: () => + import('./account/account.component').then((module) => module.AccountComponent), + canActivate: [authGuard], + }, { path: '**', redirectTo: 'login' }, ]; diff --git a/listify-client/src/app/tasks/task-push.service.ts b/listify-client/src/app/tasks/task-push.service.ts new file mode 100644 index 0000000..a768ddc --- /dev/null +++ b/listify-client/src/app/tasks/task-push.service.ts @@ -0,0 +1,118 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject, signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; + +interface PushPublicKeyResponse { + enabled: boolean; + publicKey?: string; +} + +@Injectable({ providedIn: 'root' }) +export class TaskPushService { + private readonly http = inject(HttpClient); + private readonly apiUrl = '/api/tasks/push'; + readonly saving = signal(false); + readonly errorMessage = signal(null); + + supported(): boolean { + return ( + typeof window !== 'undefined' && + 'Notification' in window && + 'serviceWorker' in navigator && + 'PushManager' in window + ); + } + + permission(): NotificationPermission | 'unsupported' { + if (!this.supported()) { + return 'unsupported'; + } + + return Notification.permission; + } + + async enable(): Promise { + this.errorMessage.set(null); + + if (!this.supported()) { + this.errorMessage.set('Push-Benachrichtigungen werden nicht unterstuetzt.'); + return; + } + + this.saving.set(true); + + try { + const publicKeyResponse = await firstValueFrom( + this.http.get(`${this.apiUrl}/public-key`), + ); + + if (!publicKeyResponse.enabled || !publicKeyResponse.publicKey) { + this.errorMessage.set('Push-Benachrichtigungen sind serverseitig nicht konfiguriert.'); + return; + } + + const permission = await Notification.requestPermission(); + + if (permission !== 'granted') { + this.errorMessage.set('Benachrichtigungen wurden nicht erlaubt.'); + return; + } + + const registration = await navigator.serviceWorker.ready; + const existingSubscription = await registration.pushManager.getSubscription(); + const subscription = + existingSubscription ?? + (await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: this.urlBase64ToArrayBuffer(publicKeyResponse.publicKey), + })); + + await firstValueFrom(this.http.post(`${this.apiUrl}/subscriptions`, subscription.toJSON())); + } catch { + this.errorMessage.set('Push-Benachrichtigungen konnten nicht aktiviert werden.'); + } finally { + this.saving.set(false); + } + } + + async disableCurrentSubscription(): Promise { + this.errorMessage.set(null); + + if (!this.supported()) { + return; + } + + this.saving.set(true); + + try { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + + if (subscription) { + await firstValueFrom( + this.http.delete(`${this.apiUrl}/subscriptions`, { + body: { endpoint: subscription.endpoint }, + }), + ); + await subscription.unsubscribe(); + } + } catch { + this.errorMessage.set('Push-Benachrichtigungen konnten nicht deaktiviert werden.'); + } finally { + this.saving.set(false); + } + } + + private urlBase64ToArrayBuffer(value: string): ArrayBuffer { + const padding = '='.repeat((4 - (value.length % 4)) % 4); + const base64 = `${value}${padding}`.replace(/-/g, '+').replace(/_/g, '/'); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let index = 0; index < rawData.length; index += 1) { + outputArray[index] = rawData.charCodeAt(index); + } + + return outputArray.buffer; + } +}