From 9a721f9e865777e4109e03e7c769247166603206 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Mon, 29 Jun 2026 14:34:34 +0200 Subject: [PATCH] mails --- listify-api/src/audit/audit-log.types.ts | 1 + listify-api/src/auth/auth.controller.ts | 12 + listify-api/src/auth/auth.service.ts | 38 +++ listify-api/src/auth/auth.types.ts | 3 + listify-api/src/auth/user.entity.ts | 10 + .../1782000000000-AddTaskDigestPreferences.ts | 49 ++++ listify-api/src/mail/mail.service.ts | 119 ++++++++- listify-api/src/mail/mail.types.ts | 18 ++ .../src/mail/templates/task-digest.hbs | 78 ++++++ .../src/tasks/task-digest.service.spec.ts | 161 ++++++++++++ listify-api/src/tasks/task-digest.service.ts | 230 ++++++++++++++++++ listify-api/src/tasks/task-digest.types.ts | 2 + listify-api/src/tasks/tasks.module.ts | 12 +- .../src/app/account/account.component.html | 38 +++ .../src/app/account/account.component.scss | 50 ++++ .../src/app/account/account.component.ts | 60 ++++- listify-client/src/app/auth/auth.models.ts | 3 + listify-client/src/app/auth/auth.service.ts | 7 + 18 files changed, 884 insertions(+), 7 deletions(-) create mode 100644 listify-api/src/database/migrations/1782000000000-AddTaskDigestPreferences.ts create mode 100644 listify-api/src/mail/templates/task-digest.hbs create mode 100644 listify-api/src/tasks/task-digest.service.spec.ts create mode 100644 listify-api/src/tasks/task-digest.service.ts create mode 100644 listify-api/src/tasks/task-digest.types.ts diff --git a/listify-api/src/audit/audit-log.types.ts b/listify-api/src/audit/audit-log.types.ts index 95b7d8c..dc70bc1 100644 --- a/listify-api/src/audit/audit-log.types.ts +++ b/listify-api/src/audit/audit-log.types.ts @@ -6,6 +6,7 @@ export type AuditAction = | 'user.login_failed' | 'user.token_refreshed' | 'user.onboarding_updated' + | 'user.task_digest_updated' | 'user.mcp_api_key_created' | 'user.mcp_api_key_revoked' | 'template.created' diff --git a/listify-api/src/auth/auth.controller.ts b/listify-api/src/auth/auth.controller.ts index eca413b..49fbcac 100644 --- a/listify-api/src/auth/auth.controller.ts +++ b/listify-api/src/auth/auth.controller.ts @@ -75,4 +75,16 @@ export class AuthController { body.completed === true, ); } + + @Patch('me/task-digest') + @UseGuards(JwtAuthGuard) + updateTaskDigestPreference( + @Req() request: AuthenticatedRequest, + @Body() body: { preference?: unknown }, + ) { + return this.authService.updateTaskDigestPreference( + request.user!.sub, + body.preference, + ); + } } diff --git a/listify-api/src/auth/auth.service.ts b/listify-api/src/auth/auth.service.ts index acc9669..9858ac9 100644 --- a/listify-api/src/auth/auth.service.ts +++ b/listify-api/src/auth/auth.service.ts @@ -23,6 +23,7 @@ import { PublicUserSearchResult, } from './auth.types'; import { AppEvents } from '../events/app-events'; +import type { TaskDigestPreference } from '../tasks/task-digest.types'; import { RefreshTokenEntity } from './refresh-token.entity'; import { UserEntity } from './user.entity'; @@ -359,6 +360,34 @@ export class AuthService { return this.toPublicUser(savedUser); } + async updateTaskDigestPreference( + userId: string, + preference: unknown, + ): Promise { + const user = await this.usersRepository.findOne({ + where: { id: userId }, + }); + + if (!user) { + throw new UnauthorizedException('Authenticated user is required.'); + } + + user.taskDigestPreference = this.normalizeTaskDigestPreference(preference); + + const savedUser = await this.usersRepository.save(user); + + await this.auditLogService?.record({ + actorUserId: savedUser.id, + actorEmail: savedUser.email, + action: 'user.task_digest_updated', + entityType: 'user', + entityId: savedUser.id, + metadata: { taskDigestPreference: savedUser.taskDigestPreference }, + }); + + return this.toPublicUser(savedUser); + } + private normalizeEmail(email?: string): string { const normalizedEmail = email?.trim().toLowerCase(); @@ -377,6 +406,14 @@ export class AuthService { return normalizedName || undefined; } + private normalizeTaskDigestPreference(value: unknown): TaskDigestPreference { + if (value === 'none' || value === 'morning' || value === 'both') { + return value; + } + + throw new BadRequestException('Task digest preference is invalid.'); + } + private requirePassword(password?: string): string { if (!password || password.length < 8) { throw new BadRequestException( @@ -495,6 +532,7 @@ export class AuthService { name: user.name ?? undefined, verified: user.verified, onboardingCompleted: user.onboardingCompleted === true, + taskDigestPreference: user.taskDigestPreference ?? 'both', }; } } diff --git a/listify-api/src/auth/auth.types.ts b/listify-api/src/auth/auth.types.ts index eb4110f..d92fca3 100644 --- a/listify-api/src/auth/auth.types.ts +++ b/listify-api/src/auth/auth.types.ts @@ -1,4 +1,5 @@ import { Request } from 'express'; +import type { TaskDigestPreference } from '../tasks/task-digest.types'; export interface AuthUser { id: string; @@ -8,6 +9,7 @@ export interface AuthUser { verificationToken?: string; verified: boolean; onboardingCompleted: boolean; + taskDigestPreference: TaskDigestPreference; } export interface AuthTokens { @@ -36,6 +38,7 @@ export interface PublicUser { name?: string; verified: boolean; onboardingCompleted: boolean; + taskDigestPreference: TaskDigestPreference; } export interface PublicUserSearchResult { diff --git a/listify-api/src/auth/user.entity.ts b/listify-api/src/auth/user.entity.ts index a857ae5..bc1c589 100644 --- a/listify-api/src/auth/user.entity.ts +++ b/listify-api/src/auth/user.entity.ts @@ -12,6 +12,7 @@ import { ListTemplateShareEntity } from '../list-templates/list-template-share.e import { UserListEntity } from '../lists/user-list.entity'; import { UserListShareEntity } from '../lists/user-list-share.entity'; import { UserTaskEntity } from '../tasks/user-task.entity'; +import type { TaskDigestPreference } from '../tasks/task-digest.types'; import { RefreshTokenEntity } from './refresh-token.entity'; @Entity('users') @@ -39,6 +40,15 @@ export class UserEntity { @Column({ type: 'boolean', default: false }) onboardingCompleted!: boolean; + @Column({ type: 'varchar', length: 16, default: 'both' }) + taskDigestPreference!: TaskDigestPreference; + + @Column({ type: 'varchar', length: 10, nullable: true }) + taskDigestMorningProcessedDate?: string | null; + + @Column({ type: 'varchar', length: 10, nullable: true }) + taskDigestAfternoonProcessedDate?: string | null; + @Index('IDX_users_mcp_api_key_hash', { unique: true }) @Column({ type: 'varchar', length: 64, nullable: true }) mcpApiKeyHash?: string | null; diff --git a/listify-api/src/database/migrations/1782000000000-AddTaskDigestPreferences.ts b/listify-api/src/database/migrations/1782000000000-AddTaskDigestPreferences.ts new file mode 100644 index 0000000..d140141 --- /dev/null +++ b/listify-api/src/database/migrations/1782000000000-AddTaskDigestPreferences.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTaskDigestPreferences1782000000000 implements MigrationInterface { + name = 'AddTaskDigestPreferences1782000000000'; + + public async up(queryRunner: QueryRunner): Promise { + const usersTable = await queryRunner.getTable('users'); + + if (!usersTable?.findColumnByName('taskDigestPreference')) { + await queryRunner.query( + "ALTER TABLE `users` ADD `taskDigestPreference` varchar(16) NOT NULL DEFAULT 'both'", + ); + } + + if (!usersTable?.findColumnByName('taskDigestMorningProcessedDate')) { + await queryRunner.query( + 'ALTER TABLE `users` ADD `taskDigestMorningProcessedDate` varchar(10) NULL', + ); + } + + if (!usersTable?.findColumnByName('taskDigestAfternoonProcessedDate')) { + await queryRunner.query( + 'ALTER TABLE `users` ADD `taskDigestAfternoonProcessedDate` varchar(10) NULL', + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + const usersTable = await queryRunner.getTable('users'); + + if (usersTable?.findColumnByName('taskDigestAfternoonProcessedDate')) { + await queryRunner.query( + 'ALTER TABLE `users` DROP COLUMN `taskDigestAfternoonProcessedDate`', + ); + } + + if (usersTable?.findColumnByName('taskDigestMorningProcessedDate')) { + await queryRunner.query( + 'ALTER TABLE `users` DROP COLUMN `taskDigestMorningProcessedDate`', + ); + } + + if (usersTable?.findColumnByName('taskDigestPreference')) { + await queryRunner.query( + 'ALTER TABLE `users` DROP COLUMN `taskDigestPreference`', + ); + } + } +} diff --git a/listify-api/src/mail/mail.service.ts b/listify-api/src/mail/mail.service.ts index 08eba9e..de4868d 100644 --- a/listify-api/src/mail/mail.service.ts +++ b/listify-api/src/mail/mail.service.ts @@ -4,7 +4,11 @@ import { MailerService } from '@nestjs-modules/mailer'; import { existsSync, readFileSync } from 'fs'; import { compile, TemplateDelegate } from 'handlebars'; import { join } from 'path'; -import { ListReminderEmailPayload, SentEmail } from './mail.types'; +import { + ListReminderEmailPayload, + SentEmail, + TaskDigestEmailPayload, +} from './mail.types'; @Injectable() export class MailService { @@ -12,6 +16,7 @@ export class MailService { private readonly sentEmails: SentEmail[] = []; private verificationTemplate?: TemplateDelegate; private listReminderTemplate?: TemplateDelegate; + private taskDigestTemplate?: TemplateDelegate; constructor( private readonly configService: ConfigService, @@ -32,7 +37,9 @@ export class MailService { this.sentEmails.push(email); if (!this.mailEnabled()) { - this.logger.log(`Verification email queued for ${to}. Mail sending is disabled.`); + this.logger.log( + `Verification email queued for ${to}. Mail sending is disabled.`, + ); return; } @@ -96,7 +103,9 @@ export class MailService { this.sentEmails.push(email); if (!this.mailEnabled()) { - this.logger.log(`List reminder email queued for ${to}. Mail sending is disabled.`); + this.logger.log( + `List reminder email queued for ${to}. Mail sending is disabled.`, + ); return; } @@ -128,6 +137,82 @@ export class MailService { } } + async sendTaskDigestEmail( + to: string, + payload: TaskDigestEmailPayload, + ): Promise { + const taskCount = payload.todayTasks.length + payload.overdueTasks.length; + const subject = `Deine Tasks fuer heute (${taskCount})`; + const email: SentEmail = { + to, + subject, + text: [ + `Deine offenen Listify Tasks fuer ${this.formatDay(payload.date)}:`, + '', + payload.todayTasks.length > 0 ? 'Heute:' : '', + ...payload.todayTasks.map((task) => `- ${task.title}`), + payload.overdueTasks.length > 0 ? '' : '', + payload.overdueTasks.length > 0 ? 'Ueberfaellig:' : '', + ...payload.overdueTasks.map( + (task) => `- ${task.title} (${this.formatDay(task.dueDate)})`, + ), + '', + `Tasks oeffnen: ${payload.tasksUrl}`, + ] + .filter((line, index, lines) => line !== '' || lines[index - 1] !== '') + .join('\n'), + taskDigestDate: payload.date, + taskDigestSlot: payload.slot, + taskTitles: [...payload.todayTasks, ...payload.overdueTasks].map( + (task) => task.title, + ), + }; + + this.sentEmails.push(email); + + if (!this.mailEnabled()) { + this.logger.log( + `Task digest email queued for ${to}. Mail sending is disabled.`, + ); + return; + } + + try { + await this.mailerService.sendMail({ + to, + subject: email.subject, + text: email.text, + html: this.renderTaskDigestTemplate({ + appName: this.configService.get('MAIL_FROM_NAME', 'Listify'), + clientUrl: this.configService.get( + 'CLIENT_URL', + 'http://localhost:4200', + ), + displayName: payload.displayName, + slotLabel: + payload.slot === 'morning' + ? 'Morgenuebersicht' + : 'Nachmittagsuebersicht', + dateLabel: this.formatDay(payload.date), + tasksUrl: payload.tasksUrl, + todayTasks: payload.todayTasks, + overdueTasks: payload.overdueTasks, + todayTaskCount: payload.todayTasks.length, + overdueTaskCount: payload.overdueTasks.length, + taskCount, + currentYear: new Date().getFullYear(), + }), + }); + this.logger.log(`Task digest email sent to ${to}.`); + } catch (error) { + this.logger.error( + `Task digest email could not be sent to ${to}.`, + error instanceof Error ? error.stack : undefined, + ); + throw error; + } + } + getSentEmails(): SentEmail[] { return [...this.sentEmails]; } @@ -164,6 +249,34 @@ export class MailService { return this.listReminderTemplate(context); } + private renderTaskDigestTemplate(context: { + appName: string; + clientUrl: string; + displayName?: string; + slotLabel: string; + dateLabel: string; + tasksUrl: string; + todayTasks: TaskDigestEmailPayload['todayTasks']; + overdueTasks: TaskDigestEmailPayload['overdueTasks']; + todayTaskCount: number; + overdueTaskCount: number; + taskCount: number; + currentYear: number; + }): string { + this.taskDigestTemplate ??= compile( + readFileSync(this.resolveTemplatePath('task-digest.hbs'), 'utf8'), + { strict: true }, + ); + + return this.taskDigestTemplate(context); + } + + private formatDay(value: string): string { + const [year, month, day] = value.split('-'); + + return year && month && day ? `${day}.${month}.${year}` : value; + } + private resolveTemplatePath(fileName: string): string { const candidates = [ join(process.cwd(), 'dist', 'mail', 'templates', fileName), diff --git a/listify-api/src/mail/mail.types.ts b/listify-api/src/mail/mail.types.ts index d4921d1..77e2528 100644 --- a/listify-api/src/mail/mail.types.ts +++ b/listify-api/src/mail/mail.types.ts @@ -6,6 +6,9 @@ export interface SentEmail { listId?: string; listUrl?: string; openItemTitles?: string[]; + taskDigestDate?: string; + taskDigestSlot?: string; + taskTitles?: string[]; } export interface ListReminderEmailItem { @@ -20,3 +23,18 @@ export interface ListReminderEmailPayload { listUrl: string; openItems: ListReminderEmailItem[]; } + +export interface TaskDigestEmailItem { + title: string; + notes?: string; + dueDate: string; +} + +export interface TaskDigestEmailPayload { + displayName?: string; + slot: 'morning' | 'afternoon'; + date: string; + tasksUrl: string; + todayTasks: TaskDigestEmailItem[]; + overdueTasks: TaskDigestEmailItem[]; +} diff --git a/listify-api/src/mail/templates/task-digest.hbs b/listify-api/src/mail/templates/task-digest.hbs new file mode 100644 index 0000000..03f9e90 --- /dev/null +++ b/listify-api/src/mail/templates/task-digest.hbs @@ -0,0 +1,78 @@ + + + + + + Listify Tasks + + + + + + +
+ + + + + + + + + + + + +
+
{{appName}}
+

{{slotLabel}} fuer deine Tasks

+
+

+ {{#if displayName}}Hallo {{displayName}}, {{/if}}du hast {{taskCount}} offene Tasks fuer {{dateLabel}} oder frueher. +

+ + {{#if overdueTaskCount}} +

Ueberfaellig

+
    + {{#each overdueTasks}} +
  • + {{title}} + - faellig am {{dueDate}} + {{#if notes}}
    {{notes}}
    {{/if}} +
  • + {{/each}} +
+ {{/if}} + + {{#if todayTaskCount}} +

Heute

+
    + {{#each todayTasks}} +
  • + {{title}} + {{#if notes}}
    {{notes}}
    {{/if}} +
  • + {{/each}} +
+ {{/if}} + + + + + +
+ + Tasks oeffnen + +
+ +

+ {{tasksUrl}} +

+
+
{{appName}} - Listen, Templates und Tagesaufgaben.
+
© {{currentYear}} {{appName}}
+
+
+ + diff --git a/listify-api/src/tasks/task-digest.service.spec.ts b/listify-api/src/tasks/task-digest.service.spec.ts new file mode 100644 index 0000000..4c708ed --- /dev/null +++ b/listify-api/src/tasks/task-digest.service.spec.ts @@ -0,0 +1,161 @@ +import { ConfigService } from '@nestjs/config'; +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 { UserTaskEntity } from './user-task.entity'; + +describe('TaskDigestService', () => { + let usersRepository: InMemoryRepository; + let tasksRepository: InMemoryRepository; + let mailService: Pick; + let service: TaskDigestService; + + beforeEach(() => { + usersRepository = new InMemoryRepository(); + tasksRepository = new InMemoryRepository(); + mailService = { + sendTaskDigestEmail: jest.fn().mockResolvedValue(undefined), + }; + service = new TaskDigestService( + usersRepository as never, + tasksRepository as never, + new ConfigService({ + CLIENT_URL: 'http://client.test', + TASK_DIGEST_TIMEZONE: 'Europe/Budapest', + }), + mailService as MailService, + ); + }); + + it('sends morning digest with today and overdue open tasks', async () => { + await saveUser({ taskDigestPreference: 'both' }); + await saveTask({ id: 'today-task', dueDate: '2026-06-29' }); + await saveTask({ id: 'overdue-task', title: 'Alt', dueDate: '2026-06-28' }); + await saveTask({ + id: 'future-task', + title: 'Zukunft', + dueDate: '2026-06-30', + }); + await saveTask({ + id: 'done-task', + title: 'Fertig', + dueDate: '2026-06-29', + completed: true, + }); + + await service.processDueDigests(new Date('2026-06-29T07:00:00.000Z')); + + expect(mailService.sendTaskDigestEmail).toHaveBeenCalledWith( + 'owner@example.com', + { + displayName: 'Owner', + 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, + ).toBe('2026-06-29'); + }); + + it('does not send when there are no due tasks but marks the slot processed', async () => { + await saveUser({ taskDigestPreference: 'both' }); + await saveTask({ id: 'future-task', dueDate: '2026-06-30' }); + + await service.processDueDigests(new Date('2026-06-29T07:00:00.000Z')); + + expect(mailService.sendTaskDigestEmail).not.toHaveBeenCalled(); + expect( + (await usersRepository.findOne({ where: { id: 'owner-1' } })) + ?.taskDigestMorningProcessedDate, + ).toBe('2026-06-29'); + }); + + it('respects none and morning-only preferences', async () => { + await saveUser({ + id: 'none-user', + email: 'none@example.com', + taskDigestPreference: 'none', + }); + await saveUser({ + id: 'morning-user', + email: 'morning@example.com', + taskDigestPreference: 'morning', + }); + await saveUser({ + id: 'both-user', + email: 'both@example.com', + taskDigestPreference: 'both', + }); + await saveTask({ id: 'none-task', ownerId: 'none-user' }); + await saveTask({ id: 'morning-task', ownerId: 'morning-user' }); + await saveTask({ id: 'both-task', ownerId: 'both-user' }); + + await service.processDueDigests(new Date('2026-06-29T13:00:00.000Z')); + + expect(mailService.sendTaskDigestEmail).toHaveBeenCalledTimes(1); + expect(mailService.sendTaskDigestEmail).toHaveBeenCalledWith( + 'both@example.com', + expect.objectContaining({ slot: 'afternoon' }), + ); + }); + + it('does not process outside the configured delivery hours', async () => { + await saveUser({ taskDigestPreference: 'both' }); + await saveTask({ id: 'today-task' }); + + await service.processDueDigests(new Date('2026-06-29T08:30:00.000Z')); + + expect(mailService.sendTaskDigestEmail).not.toHaveBeenCalled(); + }); + + async function saveUser(overrides: Partial = {}) { + return usersRepository.save({ + id: overrides.id ?? 'owner-1', + email: overrides.email ?? 'owner@example.com', + name: overrides.name ?? 'Owner', + passwordHash: 'hash', + verified: overrides.verified ?? true, + onboardingCompleted: false, + taskDigestPreference: overrides.taskDigestPreference ?? 'both', + taskDigestMorningProcessedDate: + overrides.taskDigestMorningProcessedDate ?? null, + taskDigestAfternoonProcessedDate: + overrides.taskDigestAfternoonProcessedDate ?? null, + createdAt: new Date(), + updatedAt: new Date(), + } as UserEntity); + } + + async function saveTask(overrides: Partial = {}) { + return tasksRepository.save({ + id: overrides.id ?? 'task-1', + ownerId: overrides.ownerId ?? 'owner-1', + title: overrides.title ?? 'Heute', + notes: overrides.notes ?? null, + dueDate: overrides.dueDate ?? '2026-06-29', + completed: overrides.completed ?? false, + completedAt: overrides.completedAt ?? null, + deletedAt: overrides.deletedAt ?? null, + createdAt: new Date(), + updatedAt: new Date(), + } as UserTaskEntity); + } +}); diff --git a/listify-api/src/tasks/task-digest.service.ts b/listify-api/src/tasks/task-digest.service.ts new file mode 100644 index 0000000..a947878 --- /dev/null +++ b/listify-api/src/tasks/task-digest.service.ts @@ -0,0 +1,230 @@ +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { IsNull, LessThanOrEqual, Repository } from 'typeorm'; +import { UserEntity } from '../auth/user.entity'; +import { MailService } from '../mail/mail.service'; +import { UserTaskEntity } from './user-task.entity'; +import type { TaskDigestSlot } from './task-digest.types'; + +@Injectable() +export class TaskDigestService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(TaskDigestService.name); + private timer?: NodeJS.Timeout; + private processing = false; + + constructor( + @InjectRepository(UserEntity) + private readonly usersRepository: Repository, + @InjectRepository(UserTaskEntity) + private readonly tasksRepository: Repository, + private readonly configService: ConfigService, + private readonly mailService: MailService, + ) {} + + onModuleInit(): void { + if (process.env.NODE_ENV === 'test') { + return; + } + + const intervalMs = Number( + this.configService.get('TASK_DIGEST_POLL_INTERVAL_MS', '60000'), + ); + + this.timer = setInterval( + () => void this.processDueDigests(), + Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 60000, + ); + void this.processDueDigests(); + } + + onModuleDestroy(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + async processDueDigests(now = new Date()): Promise { + if (this.processing) { + return; + } + + const slot = this.currentSlot(now); + + if (!slot) { + return; + } + + this.processing = true; + + try { + const dateKey = this.dateKey(now); + const users = await this.usersRepository.find({ + where: { verified: true }, + order: { email: 'ASC' }, + }); + + for (const user of users) { + if (!this.shouldProcessUser(user, slot, dateKey)) { + continue; + } + + await this.processUserDigest(user, slot, dateKey); + } + } finally { + this.processing = false; + } + } + + private async processUserDigest( + user: UserEntity, + slot: TaskDigestSlot, + dateKey: string, + ): Promise { + const tasks = await this.tasksRepository.find({ + where: { + ownerId: user.id, + deletedAt: IsNull(), + completed: false, + dueDate: LessThanOrEqual(dateKey), + }, + order: { dueDate: 'ASC', createdAt: 'ASC' }, + }); + const overdueTasks = tasks.filter((task) => task.dueDate < dateKey); + const todayTasks = tasks.filter((task) => task.dueDate === dateKey); + + if (tasks.length > 0) { + try { + await this.mailService.sendTaskDigestEmail(user.email, { + 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, + })), + }); + } catch (error) { + this.logger.error( + `Task digest could not be sent for user ${user.id}.`, + error instanceof Error ? error.stack : undefined, + ); + return; + } + } + + this.markProcessed(user, slot, dateKey); + await this.usersRepository.save(user); + } + + private shouldProcessUser( + user: UserEntity, + slot: TaskDigestSlot, + dateKey: string, + ): boolean { + const preference = user.taskDigestPreference ?? 'both'; + + if (preference === 'none') { + return false; + } + + if (slot === 'afternoon' && preference !== 'both') { + return false; + } + + return this.processedDate(user, slot) !== dateKey; + } + + private processedDate(user: UserEntity, slot: TaskDigestSlot): string | null { + return slot === 'morning' + ? (user.taskDigestMorningProcessedDate ?? null) + : (user.taskDigestAfternoonProcessedDate ?? null); + } + + private markProcessed( + user: UserEntity, + slot: TaskDigestSlot, + dateKey: string, + ): void { + if (slot === 'morning') { + user.taskDigestMorningProcessedDate = dateKey; + return; + } + + user.taskDigestAfternoonProcessedDate = dateKey; + } + + private currentSlot(now: Date): TaskDigestSlot | null { + const hour = this.localParts(now).hour; + + if (hour === 9) { + return 'morning'; + } + + if (hour === 15) { + return 'afternoon'; + } + + return null; + } + + private dateKey(now: Date): string { + const { year, month, day } = this.localParts(now); + + return `${year}-${month}-${day}`; + } + + private localParts(now: Date): { + year: string; + month: string; + day: string; + hour: number; + } { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: this.timeZone(), + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + hourCycle: 'h23', + }).formatToParts(now); + const value = (type: string) => + parts.find((part) => part.type === type)?.value ?? ''; + + return { + year: value('year'), + month: value('month'), + day: value('day'), + hour: Number(value('hour')), + }; + } + + private timeZone(): string { + return this.configService.get( + 'TASK_DIGEST_TIMEZONE', + 'Europe/Budapest', + ); + } + + private tasksUrl(): string { + const clientUrl = this.configService.get( + 'CLIENT_URL', + 'http://localhost:4200', + ); + + return `${clientUrl.replace(/\/$/, '')}/tasks`; + } +} diff --git a/listify-api/src/tasks/task-digest.types.ts b/listify-api/src/tasks/task-digest.types.ts new file mode 100644 index 0000000..a7cfb4c --- /dev/null +++ b/listify-api/src/tasks/task-digest.types.ts @@ -0,0 +1,2 @@ +export type TaskDigestPreference = 'none' | 'morning' | 'both'; +export type TaskDigestSlot = 'morning' | 'afternoon'; diff --git a/listify-api/src/tasks/tasks.module.ts b/listify-api/src/tasks/tasks.module.ts index d46c54a..891d398 100644 --- a/listify-api/src/tasks/tasks.module.ts +++ b/listify-api/src/tasks/tasks.module.ts @@ -1,15 +1,21 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; 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 { 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, TypeOrmModule.forFeature([UserTaskEntity]), AuthModule], + imports: [ + AuditModule, + MailModule, + TypeOrmModule.forFeature([UserEntity, UserTaskEntity]), + ], controllers: [TasksController], - providers: [TasksService], + providers: [TaskDigestService, TasksService], exports: [TasksService], }) export class TasksModule {} diff --git a/listify-client/src/app/account/account.component.html b/listify-client/src/app/account/account.component.html index 9c50540..b58020e 100644 --- a/listify-client/src/app/account/account.component.html +++ b/listify-client/src/app/account/account.component.html @@ -17,6 +17,44 @@ {{ auth.user()?.onboardingCompleted ? 'Onboarding abgeschlossen' : 'Onboarding offen' }} + +
+
+ +
+

Task-Mail

+

Offene Tasks von heute und ueberfaellige Tasks.

+
+
+ + + Taegliche Benachrichtigung + + @for (option of taskDigestPreferenceOptions; track option.value) { + + {{ option.label }} + + } + + + + @for (option of taskDigestPreferenceOptions; track option.value) { + @if ((auth.user()?.taskDigestPreference ?? 'both') === option.value) { +

{{ option.description }}

+ } + } + + @if (savingTaskDigestPreference) { +
+ + Speichert... +
+ } +
diff --git a/listify-client/src/app/account/account.component.scss b/listify-client/src/app/account/account.component.scss index 635383c..d3ba5a2 100644 --- a/listify-client/src/app/account/account.component.scss +++ b/listify-client/src/app/account/account.component.scss @@ -30,6 +30,56 @@ color: var(--mat-sys-primary); } +.settings-section { + display: grid; + gap: 0.8rem; + margin-top: 1rem; + padding: 0.9rem; + border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--mat-sys-surface-container-low) 36%, var(--mat-sys-surface)); +} + +.settings-heading { + display: flex; + gap: 0.75rem; + min-width: 0; +} + +.settings-heading mat-icon { + flex: 0 0 auto; + color: var(--mat-sys-primary); +} + +.settings-heading h2, +.settings-heading p, +.settings-description { + margin: 0; +} + +.settings-heading h2 { + font-size: 1rem; + font-weight: 600; +} + +.settings-heading p, +.settings-description { + color: var(--mat-sys-on-surface-variant); + line-height: 1.45; +} + +.settings-section mat-form-field { + width: 100%; +} + +.saving-row { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: var(--mat-sys-on-surface-variant); + font-size: 0.9rem; +} + mat-card-actions { flex-wrap: wrap; gap: 0.5rem; diff --git a/listify-client/src/app/account/account.component.ts b/listify-client/src/app/account/account.component.ts index 59ea2d2..4d4fe26 100644 --- a/listify-client/src/app/account/account.component.ts +++ b/listify-client/src/app/account/account.component.ts @@ -4,12 +4,24 @@ import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { finalize } from 'rxjs'; +import { getAuthErrorMessage } from '../auth/error-message'; import { AuthService } from '../auth/auth.service'; +import { TaskDigestPreference } from '../auth/auth.models'; import { OnboardingService } from '../onboarding/onboarding.service'; @Component({ selector: 'app-account', - imports: [MatButtonModule, MatCardModule, MatIconModule, MatProgressSpinnerModule], + imports: [ + MatButtonModule, + MatCardModule, + MatIconModule, + MatProgressSpinnerModule, + MatSelectModule, + MatSnackBarModule, + ], templateUrl: './account.component.html', styleUrl: './account.component.scss', }) @@ -17,11 +29,57 @@ export class AccountComponent { protected readonly auth = inject(AuthService); protected readonly onboarding = inject(OnboardingService); private readonly router = inject(Router); + private readonly snackBar = inject(MatSnackBar); + protected savingTaskDigestPreference = false; + protected readonly taskDigestPreferenceOptions: ReadonlyArray<{ + value: TaskDigestPreference; + label: string; + description: string; + }> = [ + { + value: 'both', + label: 'Morgens und nachmittags', + description: 'E-Mail um 9:00 und 15:00 Uhr, wenn Tasks offen sind.', + }, + { + value: 'morning', + label: 'Nur morgens', + description: 'E-Mail um 9:00 Uhr, wenn Tasks offen sind.', + }, + { + value: 'none', + label: 'Keine Task-Mail', + description: 'Es werden keine taeglichen Task-Mails gesendet.', + }, + ]; resetOnboarding(): void { this.onboarding.resetForCurrentUser(); } + updateTaskDigestPreference(preference: TaskDigestPreference): void { + if (this.savingTaskDigestPreference || preference === this.auth.user()?.taskDigestPreference) { + return; + } + + this.savingTaskDigestPreference = true; + this.auth + .updateTaskDigestPreference(preference) + .pipe(finalize(() => (this.savingTaskDigestPreference = false))) + .subscribe({ + next: () => { + this.snackBar.open('Task-Mail-Einstellung gespeichert.', 'OK', { + duration: 3000, + }); + }, + error: (error: unknown) => { + this.snackBar.open(getAuthErrorMessage(error), 'OK', { + duration: 5000, + }); + }, + }); + } + logout(): void { this.auth.logout(); void this.router.navigateByUrl('/login'); diff --git a/listify-client/src/app/auth/auth.models.ts b/listify-client/src/app/auth/auth.models.ts index a41befe..2eae034 100644 --- a/listify-client/src/app/auth/auth.models.ts +++ b/listify-client/src/app/auth/auth.models.ts @@ -1,9 +1,12 @@ +export type TaskDigestPreference = 'none' | 'morning' | 'both'; + export interface PublicUser { id: string; email: string; name?: string; verified: boolean; onboardingCompleted: boolean; + taskDigestPreference: TaskDigestPreference; } export interface PublicUserSearchResult { diff --git a/listify-client/src/app/auth/auth.service.ts b/listify-client/src/app/auth/auth.service.ts index a247613..2695004 100644 --- a/listify-client/src/app/auth/auth.service.ts +++ b/listify-client/src/app/auth/auth.service.ts @@ -9,6 +9,7 @@ import { RegisterRequest, RegisterResponse, ResendVerificationResponse, + TaskDigestPreference, VerifyEmailResponse, } from './auth.models'; @@ -64,6 +65,12 @@ export class AuthService { .pipe(tap((user) => this.storeUser(user))); } + updateTaskDigestPreference(preference: TaskDigestPreference): Observable { + return this.http + .patch(`${this.apiUrl}/me/task-digest`, { preference }) + .pipe(tap((user) => this.storeUser(user))); + } + accessToken(): string | null { return this.storage?.getItem(ACCESS_TOKEN_KEY) ?? null; }