pwa notifications
This commit is contained in:
85
listify-api/package-lock.json
generated
85
listify-api/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`');
|
||||
}
|
||||
}
|
||||
}
|
||||
12
listify-api/src/tasks/dto/task-push-subscription.dto.ts
Normal file
12
listify-api/src/tasks/dto/task-push-subscription.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export class SaveTaskPushSubscriptionDto {
|
||||
endpoint?: string;
|
||||
expirationTime?: number | null;
|
||||
keys?: {
|
||||
p256dh?: string;
|
||||
auth?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class DeleteTaskPushSubscriptionDto {
|
||||
endpoint?: string;
|
||||
}
|
||||
@@ -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<UserEntity>;
|
||||
let tasksRepository: InMemoryRepository<UserTaskEntity>;
|
||||
let mailService: Pick<MailService, 'sendTaskDigestEmail'>;
|
||||
let taskPushService: Pick<TaskPushService, 'sendTaskDigest'>;
|
||||
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<UserEntity> = {}) {
|
||||
|
||||
@@ -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<UserTaskEntity>,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly mailService: MailService,
|
||||
private readonly taskPushService: TaskPushService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
@@ -100,9 +102,7 @@ export class TaskDigestService implements OnModuleInit, OnModuleDestroy {
|
||||
const todayTasks = tasks.filter((task) => task.dueDate === dateKey);
|
||||
|
||||
if (tasks.length > 0) {
|
||||
try {
|
||||
await this.mailService.sendTaskDigestEmail(user.email, {
|
||||
displayName: user.name ?? undefined,
|
||||
const digestPayload = {
|
||||
slot,
|
||||
date: dateKey,
|
||||
tasksUrl: this.tasksUrl(),
|
||||
@@ -116,7 +116,14 @@ export class TaskDigestService implements OnModuleInit, OnModuleDestroy {
|
||||
notes: task.notes ?? undefined,
|
||||
dueDate: task.dueDate,
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
await this.mailService.sendTaskDigestEmail(user.email, {
|
||||
...digestPayload,
|
||||
displayName: user.name ?? undefined,
|
||||
});
|
||||
await this.taskPushService.sendTaskDigest(user.id, digestPayload);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Task digest could not be sent for user ${user.id}.`,
|
||||
|
||||
55
listify-api/src/tasks/task-push-subscription.entity.ts
Normal file
55
listify-api/src/tasks/task-push-subscription.entity.ts
Normal 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;
|
||||
}
|
||||
211
listify-api/src/tasks/task-push.service.ts
Normal file
211
listify-api/src/tasks/task-push.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -54,6 +54,50 @@
|
||||
<span>Speichert...</span>
|
||||
</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>
|
||||
</mat-card-content>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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 {
|
||||
this.auth.logout();
|
||||
void this.router.navigateByUrl('/login');
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
118
listify-client/src/app/tasks/task-push.service.ts
Normal file
118
listify-client/src/app/tasks/task-push.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user