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/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",

View File

@@ -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": {

View File

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

View File

@@ -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,

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 { 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> = {}) {

View File

@@ -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,23 +102,28 @@ export class TaskDigestService implements OnModuleInit, OnModuleDestroy {
const todayTasks = tasks.filter((task) => task.dueDate === dateKey);
if (tasks.length > 0) {
const digestPayload = {
slot,
date: dateKey,
tasksUrl: this.tasksUrl(),
todayTasks: todayTasks.map((task) => ({
title: task.title,
notes: task.notes ?? undefined,
dueDate: task.dueDate,
})),
overdueTasks: overdueTasks.map((task) => ({
title: task.title,
notes: task.notes ?? undefined,
dueDate: task.dueDate,
})),
};
try {
await this.mailService.sendTaskDigestEmail(user.email, {
...digestPayload,
displayName: user.name ?? undefined,
slot,
date: dateKey,
tasksUrl: this.tasksUrl(),
todayTasks: todayTasks.map((task) => ({
title: task.title,
notes: task.notes ?? undefined,
dueDate: task.dueDate,
})),
overdueTasks: overdueTasks.map((task) => ({
title: task.title,
notes: task.notes ?? undefined,
dueDate: task.dueDate,
})),
});
await this.taskPushService.sendTaskDigest(user.id, digestPayload);
} catch (error) {
this.logger.error(
`Task digest could not be sent for user ${user.id}.`,

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';
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,

View File

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