pwa notifications

This commit is contained in:
Bastian Wagner
2026-06-29 14:52:33 +02:00
parent 8fdee223c0
commit a033c50c25
18 changed files with 780 additions and 26 deletions

View File

@@ -18,6 +18,7 @@
"@nestjs/jwt": "^11.0.2", "@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.1", "@nestjs/typeorm": "^11.0.1",
"@types/web-push": "^3.6.4",
"handlebars": "^4.7.9", "handlebars": "^4.7.9",
"helmet": "^8.2.0", "helmet": "^8.2.0",
"mysql2": "^3.22.5", "mysql2": "^3.22.5",
@@ -26,6 +27,7 @@
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^0.3.30", "typeorm": "^0.3.30",
"web-push": "^3.6.7",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
@@ -3443,6 +3445,15 @@
"@types/superagent": "^8.1.0" "@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": { "node_modules/@types/yargs": {
"version": "17.0.35", "version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@@ -4358,6 +4369,15 @@
"node": ">=0.4.0" "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": { "node_modules/ajv": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -4589,6 +4609,18 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/assert-never": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.4.0.tgz", "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.4.0.tgz",
@@ -4790,6 +4822,12 @@
"readable-stream": "^3.4.0" "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": { "node_modules/body-parser": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -7664,6 +7702,15 @@
"entities": "^4.5.0" "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": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -7684,6 +7731,19 @@
"url": "https://opencollective.com/express" "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": { "node_modules/human-signals": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@@ -9762,6 +9822,12 @@
"node": ">=6" "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": { "node_modules/minimatch": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -14131,6 +14197,25 @@
"defaults": "^1.0.3" "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": { "node_modules/web-resource-inliner": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/web-resource-inliner/-/web-resource-inliner-8.0.0.tgz", "resolved": "https://registry.npmjs.org/web-resource-inliner/-/web-resource-inliner-8.0.0.tgz",

View File

@@ -33,6 +33,7 @@
"@nestjs/jwt": "^11.0.2", "@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.1", "@nestjs/typeorm": "^11.0.1",
"@types/web-push": "^3.6.4",
"handlebars": "^4.7.9", "handlebars": "^4.7.9",
"helmet": "^8.2.0", "helmet": "^8.2.0",
"mysql2": "^3.22.5", "mysql2": "^3.22.5",
@@ -41,6 +42,7 @@
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^0.3.30", "typeorm": "^0.3.30",
"web-push": "^3.6.7",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -11,6 +11,7 @@ import { ListTemplateEntity } from '../list-templates/list-template.entity';
import { ListTemplateShareEntity } from '../list-templates/list-template-share.entity'; import { ListTemplateShareEntity } from '../list-templates/list-template-share.entity';
import { UserListEntity } from '../lists/user-list.entity'; import { UserListEntity } from '../lists/user-list.entity';
import { UserListShareEntity } from '../lists/user-list-share.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 { UserTaskEntity } from '../tasks/user-task.entity';
import type { TaskDigestPreference } from '../tasks/task-digest.types'; import type { TaskDigestPreference } from '../tasks/task-digest.types';
import { RefreshTokenEntity } from './refresh-token.entity'; import { RefreshTokenEntity } from './refresh-token.entity';
@@ -86,6 +87,12 @@ export class UserEntity {
@OneToMany(() => UserTaskEntity, (task) => task.owner) @OneToMany(() => UserTaskEntity, (task) => task.owner)
tasks?: UserTaskEntity[]; tasks?: UserTaskEntity[];
@OneToMany(
() => TaskPushSubscriptionEntity,
(subscription) => subscription.user,
)
taskPushSubscriptions?: TaskPushSubscriptionEntity[];
@OneToMany(() => ListTemplateShareEntity, (share) => share.user) @OneToMany(() => ListTemplateShareEntity, (share) => share.user)
sharedTemplates?: ListTemplateShareEntity[]; sharedTemplates?: ListTemplateShareEntity[];
} }

View File

@@ -13,6 +13,7 @@ import { ListTemplateShareEntity } from '../list-templates/list-template-share.e
import { TemplateSeedEntity } from '../list-templates/template-seed.entity'; import { TemplateSeedEntity } from '../list-templates/template-seed.entity';
import { UserListEntity } from '../lists/user-list.entity'; import { UserListEntity } from '../lists/user-list.entity';
import { UserListItemEntity } from '../lists/user-list-item.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 { UserTaskEntity } from '../tasks/user-task.entity';
import { import {
databaseLoggerOptionsFromEnv, databaseLoggerOptionsFromEnv,
@@ -42,6 +43,7 @@ export default new DataSource({
ListTemplateItemEntity, ListTemplateItemEntity,
ListTemplateShareEntity, ListTemplateShareEntity,
TemplateSeedEntity, TemplateSeedEntity,
TaskPushSubscriptionEntity,
UserListEntity, UserListEntity,
UserListItemEntity, UserListItemEntity,
UserTaskEntity, UserTaskEntity,

View File

@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateTaskPushSubscriptions1782100000000 implements MigrationInterface {
name = 'CreateTaskPushSubscriptions1782100000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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`');
}
}
}

View File

@@ -0,0 +1,12 @@
export class SaveTaskPushSubscriptionDto {
endpoint?: string;
expirationTime?: number | null;
keys?: {
p256dh?: string;
auth?: string;
};
}
export class DeleteTaskPushSubscriptionDto {
endpoint?: string;
}

View File

@@ -3,12 +3,14 @@ import { UserEntity } from '../auth/user.entity';
import { MailService } from '../mail/mail.service'; import { MailService } from '../mail/mail.service';
import { InMemoryRepository } from '../testing/in-memory-repository'; import { InMemoryRepository } from '../testing/in-memory-repository';
import { TaskDigestService } from './task-digest.service'; import { TaskDigestService } from './task-digest.service';
import { TaskPushService } from './task-push.service';
import { UserTaskEntity } from './user-task.entity'; import { UserTaskEntity } from './user-task.entity';
describe('TaskDigestService', () => { describe('TaskDigestService', () => {
let usersRepository: InMemoryRepository<UserEntity>; let usersRepository: InMemoryRepository<UserEntity>;
let tasksRepository: InMemoryRepository<UserTaskEntity>; let tasksRepository: InMemoryRepository<UserTaskEntity>;
let mailService: Pick<MailService, 'sendTaskDigestEmail'>; let mailService: Pick<MailService, 'sendTaskDigestEmail'>;
let taskPushService: Pick<TaskPushService, 'sendTaskDigest'>;
let service: TaskDigestService; let service: TaskDigestService;
beforeEach(() => { beforeEach(() => {
@@ -17,6 +19,9 @@ describe('TaskDigestService', () => {
mailService = { mailService = {
sendTaskDigestEmail: jest.fn().mockResolvedValue(undefined), sendTaskDigestEmail: jest.fn().mockResolvedValue(undefined),
}; };
taskPushService = {
sendTaskDigest: jest.fn().mockResolvedValue(undefined),
};
service = new TaskDigestService( service = new TaskDigestService(
usersRepository as never, usersRepository as never,
tasksRepository as never, tasksRepository as never,
@@ -25,6 +30,7 @@ describe('TaskDigestService', () => {
TASK_DIGEST_TIMEZONE: 'Europe/Budapest', TASK_DIGEST_TIMEZONE: 'Europe/Budapest',
}), }),
mailService as MailService, 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( expect(
(await usersRepository.findOne({ where: { id: 'owner-1' } })) (await usersRepository.findOne({ where: { id: 'owner-1' } }))
?.taskDigestMorningProcessedDate, ?.taskDigestMorningProcessedDate,
@@ -82,6 +107,7 @@ describe('TaskDigestService', () => {
await service.processDueDigests(new Date('2026-06-29T07:00:00.000Z')); await service.processDueDigests(new Date('2026-06-29T07:00:00.000Z'));
expect(mailService.sendTaskDigestEmail).not.toHaveBeenCalled(); expect(mailService.sendTaskDigestEmail).not.toHaveBeenCalled();
expect(taskPushService.sendTaskDigest).not.toHaveBeenCalled();
expect( expect(
(await usersRepository.findOne({ where: { id: 'owner-1' } })) (await usersRepository.findOne({ where: { id: 'owner-1' } }))
?.taskDigestMorningProcessedDate, ?.taskDigestMorningProcessedDate,
@@ -111,6 +137,7 @@ describe('TaskDigestService', () => {
await service.processDueDigests(new Date('2026-06-29T13:00:00.000Z')); await service.processDueDigests(new Date('2026-06-29T13:00:00.000Z'));
expect(mailService.sendTaskDigestEmail).toHaveBeenCalledTimes(1); expect(mailService.sendTaskDigestEmail).toHaveBeenCalledTimes(1);
expect(taskPushService.sendTaskDigest).toHaveBeenCalledTimes(1);
expect(mailService.sendTaskDigestEmail).toHaveBeenCalledWith( expect(mailService.sendTaskDigestEmail).toHaveBeenCalledWith(
'both@example.com', 'both@example.com',
expect.objectContaining({ slot: 'afternoon' }), expect.objectContaining({ slot: 'afternoon' }),
@@ -124,6 +151,7 @@ describe('TaskDigestService', () => {
await service.processDueDigests(new Date('2026-06-29T08:30:00.000Z')); await service.processDueDigests(new Date('2026-06-29T08:30:00.000Z'));
expect(mailService.sendTaskDigestEmail).not.toHaveBeenCalled(); expect(mailService.sendTaskDigestEmail).not.toHaveBeenCalled();
expect(taskPushService.sendTaskDigest).not.toHaveBeenCalled();
}); });
async function saveUser(overrides: Partial<UserEntity> = {}) { async function saveUser(overrides: Partial<UserEntity> = {}) {

View File

@@ -9,6 +9,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, LessThanOrEqual, Repository } from 'typeorm'; import { IsNull, LessThanOrEqual, Repository } from 'typeorm';
import { UserEntity } from '../auth/user.entity'; import { UserEntity } from '../auth/user.entity';
import { MailService } from '../mail/mail.service'; import { MailService } from '../mail/mail.service';
import { TaskPushService } from './task-push.service';
import { UserTaskEntity } from './user-task.entity'; import { UserTaskEntity } from './user-task.entity';
import type { TaskDigestSlot } from './task-digest.types'; import type { TaskDigestSlot } from './task-digest.types';
@@ -25,6 +26,7 @@ export class TaskDigestService implements OnModuleInit, OnModuleDestroy {
private readonly tasksRepository: Repository<UserTaskEntity>, private readonly tasksRepository: Repository<UserTaskEntity>,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly mailService: MailService, private readonly mailService: MailService,
private readonly taskPushService: TaskPushService,
) {} ) {}
onModuleInit(): void { onModuleInit(): void {
@@ -100,23 +102,28 @@ export class TaskDigestService implements OnModuleInit, OnModuleDestroy {
const todayTasks = tasks.filter((task) => task.dueDate === dateKey); const todayTasks = tasks.filter((task) => task.dueDate === dateKey);
if (tasks.length > 0) { 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 { try {
await this.mailService.sendTaskDigestEmail(user.email, { await this.mailService.sendTaskDigestEmail(user.email, {
...digestPayload,
displayName: user.name ?? undefined, 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) { } catch (error) {
this.logger.error( this.logger.error(
`Task digest could not be sent for user ${user.id}.`, `Task digest could not be sent for user ${user.id}.`,

View File

@@ -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;
}

View File

@@ -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<TaskPushSubscriptionEntity>,
private readonly configService: ConfigService,
) {}
publicKey(): { enabled: boolean; publicKey?: string } {
const publicKey = this.configService.get<string>('VAPID_PUBLIC_KEY');
const privateKey = this.configService.get<string>('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<void> {
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<void> {
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<string>('VAPID_PUBLIC_KEY');
const privateKey = this.configService.get<string>('VAPID_PRIVATE_KEY');
if (!publicKey || !privateKey) {
return false;
}
webPush.setVapidDetails(
this.configService.get<string>(
'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;
}
}

View File

@@ -13,14 +13,22 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CreateTaskDto } from './dto/create-task.dto'; import { CreateTaskDto } from './dto/create-task.dto';
import {
DeleteTaskPushSubscriptionDto,
SaveTaskPushSubscriptionDto,
} from './dto/task-push-subscription.dto';
import { UpdateTaskDto } from './dto/update-task.dto'; import { UpdateTaskDto } from './dto/update-task.dto';
import { TaskPushService } from './task-push.service';
import { TasksService } from './tasks.service'; import { TasksService } from './tasks.service';
import type { AuthenticatedRequest } from '../auth/auth.types'; import type { AuthenticatedRequest } from '../auth/auth.types';
@Controller('tasks') @Controller('tasks')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
export class TasksController { export class TasksController {
constructor(private readonly tasksService: TasksService) {} constructor(
private readonly taskPushService: TaskPushService,
private readonly tasksService: TasksService,
) {}
@Post() @Post()
createTask( createTask(
@@ -38,6 +46,33 @@ export class TasksController {
return this.tasksService.listTasks(this.requireUserId(request), date); 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') @Get(':taskId')
getTask( getTask(
@Req() request: AuthenticatedRequest, @Req() request: AuthenticatedRequest,

View File

@@ -4,20 +4,24 @@ import { AuditModule } from '../audit/audit.module';
import { UserEntity } from '../auth/user.entity'; import { UserEntity } from '../auth/user.entity';
import { MailModule } from '../mail/mail.module'; import { MailModule } from '../mail/mail.module';
import { TaskDigestService } from './task-digest.service'; 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 { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service'; import { TasksService } from './tasks.service';
import { UserTaskEntity } from './user-task.entity'; import { UserTaskEntity } from './user-task.entity';
import { AuthModule } from 'src/auth/auth.module';
@Module({ @Module({
imports: [ imports: [
AuditModule, AuditModule,
MailModule, MailModule,
TypeOrmModule.forFeature([UserEntity, UserTaskEntity]), TypeOrmModule.forFeature([
AuthModule UserEntity,
TaskPushSubscriptionEntity,
UserTaskEntity,
]),
], ],
controllers: [TasksController], controllers: [TasksController],
providers: [TaskDigestService, TasksService], providers: [TaskDigestService, TaskPushService, TasksService],
exports: [TasksService], exports: [TasksService],
}) })
export class TasksModule {} export class TasksModule {}

View File

@@ -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']; const SHELL_ASSETS = ['/', '/index.html', '/manifest.webmanifest', '/pwa-icon.svg'];
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
@@ -15,11 +15,7 @@ self.addEventListener('activate', (event) => {
caches caches
.keys() .keys()
.then((keys) => .then((keys) =>
Promise.all( Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))),
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key)),
),
) )
.then(() => self.clients.claim()), .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);
}),
);
});

View File

@@ -54,6 +54,50 @@
<span>Speichert...</span> <span>Speichert...</span>
</div> </div>
} }
<div class="push-row">
<div>
<h3>PWA-Benachrichtigungen</h3>
<p>
Gleiche Tasks und Zeiten wie die Task-Mail. Die App braucht dafuer eine
Browser-Erlaubnis.
</p>
</div>
@if (!taskPush.supported()) {
<span class="push-state">Nicht unterstuetzt</span>
} @else if (taskPush.permission() === 'granted') {
<button
mat-stroked-button
type="button"
[disabled]="taskPush.saving()"
(click)="disablePushNotifications()"
>
@if (taskPush.saving()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">notifications_off</mat-icon>
}
Deaktivieren
</button>
} @else {
<button
mat-stroked-button
type="button"
[disabled]="
taskPush.saving() || (auth.user()?.taskDigestPreference ?? 'both') === 'none'
"
(click)="enablePushNotifications()"
>
@if (taskPush.saving()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">notifications_active</mat-icon>
}
Aktivieren
</button>
}
</div>
</section> </section>
</mat-card-content> </mat-card-content>

View File

@@ -80,6 +80,33 @@
font-size: 0.9rem; 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 { mat-card-actions {
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;

View File

@@ -11,6 +11,7 @@ import { getAuthErrorMessage } from '../auth/error-message';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { TaskDigestPreference } from '../auth/auth.models'; import { TaskDigestPreference } from '../auth/auth.models';
import { OnboardingService } from '../onboarding/onboarding.service'; import { OnboardingService } from '../onboarding/onboarding.service';
import { TaskPushService } from '../tasks/task-push.service';
@Component({ @Component({
selector: 'app-account', selector: 'app-account',
@@ -28,6 +29,7 @@ import { OnboardingService } from '../onboarding/onboarding.service';
export class AccountComponent { export class AccountComponent {
protected readonly auth = inject(AuthService); protected readonly auth = inject(AuthService);
protected readonly onboarding = inject(OnboardingService); protected readonly onboarding = inject(OnboardingService);
protected readonly taskPush = inject(TaskPushService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar); private readonly snackBar = inject(MatSnackBar);
protected savingTaskDigestPreference = false; protected savingTaskDigestPreference = false;
@@ -71,6 +73,9 @@ export class AccountComponent {
this.snackBar.open('Task-Mail-Einstellung gespeichert.', 'OK', { this.snackBar.open('Task-Mail-Einstellung gespeichert.', 'OK', {
duration: 3000, duration: 3000,
}); });
if (preference === 'none') {
void this.taskPush.disableCurrentSubscription();
}
}, },
error: (error: unknown) => { error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { this.snackBar.open(getAuthErrorMessage(error), 'OK', {
@@ -80,6 +85,36 @@ export class AccountComponent {
}); });
} }
async enablePushNotifications(): Promise<void> {
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<void> {
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 { logout(): void {
this.auth.logout(); this.auth.logout();
void this.router.navigateByUrl('/login'); void this.router.navigateByUrl('/login');

View File

@@ -3,7 +3,6 @@ import { authGuard } from './auth/auth.guard';
import { unauthGuard } from './auth/unauth.guard'; import { unauthGuard } from './auth/unauth.guard';
import { LoginComponent } from './auth/login/login.component'; import { LoginComponent } from './auth/login/login.component';
import { RegisterComponent } from './auth/register/register.component'; import { RegisterComponent } from './auth/register/register.component';
import { AccountComponent } from './account/account.component';
import { VerifyEmailComponent } from './auth/verify-email/verify-email.component'; import { VerifyEmailComponent } from './auth/verify-email/verify-email.component';
import { ListDetailComponent } from './lists/list-detail/list-detail.component'; import { ListDetailComponent } from './lists/list-detail/list-detail.component';
import { ListsComponent } from './lists/lists.component'; import { ListsComponent } from './lists/lists.component';
@@ -54,6 +53,11 @@ export const routes: Routes = [
), ),
canActivate: [authGuard], canActivate: [authGuard],
}, },
{ path: 'account', component: AccountComponent, canActivate: [authGuard] }, {
path: 'account',
loadComponent: () =>
import('./account/account.component').then((module) => module.AccountComponent),
canActivate: [authGuard],
},
{ path: '**', redirectTo: 'login' }, { path: '**', redirectTo: 'login' },
]; ];

View File

@@ -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<string | null>(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<void> {
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<PushPublicKeyResponse>(`${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<void> {
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;
}
}