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/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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 { 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> = {}) {
|
||||||
|
|||||||
@@ -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}.`,
|
||||||
|
|||||||
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';
|
} 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,
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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' },
|
||||||
];
|
];
|
||||||
|
|||||||
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