mails
This commit is contained in:
@@ -6,6 +6,7 @@ export type AuditAction =
|
|||||||
| 'user.login_failed'
|
| 'user.login_failed'
|
||||||
| 'user.token_refreshed'
|
| 'user.token_refreshed'
|
||||||
| 'user.onboarding_updated'
|
| 'user.onboarding_updated'
|
||||||
|
| 'user.task_digest_updated'
|
||||||
| 'user.mcp_api_key_created'
|
| 'user.mcp_api_key_created'
|
||||||
| 'user.mcp_api_key_revoked'
|
| 'user.mcp_api_key_revoked'
|
||||||
| 'template.created'
|
| 'template.created'
|
||||||
|
|||||||
@@ -75,4 +75,16 @@ export class AuthController {
|
|||||||
body.completed === true,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
PublicUserSearchResult,
|
PublicUserSearchResult,
|
||||||
} from './auth.types';
|
} from './auth.types';
|
||||||
import { AppEvents } from '../events/app-events';
|
import { AppEvents } from '../events/app-events';
|
||||||
|
import type { TaskDigestPreference } from '../tasks/task-digest.types';
|
||||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
import { RefreshTokenEntity } from './refresh-token.entity';
|
||||||
import { UserEntity } from './user.entity';
|
import { UserEntity } from './user.entity';
|
||||||
|
|
||||||
@@ -359,6 +360,34 @@ export class AuthService {
|
|||||||
return this.toPublicUser(savedUser);
|
return this.toPublicUser(savedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateTaskDigestPreference(
|
||||||
|
userId: string,
|
||||||
|
preference: unknown,
|
||||||
|
): Promise<PublicUser> {
|
||||||
|
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 {
|
private normalizeEmail(email?: string): string {
|
||||||
const normalizedEmail = email?.trim().toLowerCase();
|
const normalizedEmail = email?.trim().toLowerCase();
|
||||||
|
|
||||||
@@ -377,6 +406,14 @@ export class AuthService {
|
|||||||
return normalizedName || undefined;
|
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 {
|
private requirePassword(password?: string): string {
|
||||||
if (!password || password.length < 8) {
|
if (!password || password.length < 8) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -495,6 +532,7 @@ export class AuthService {
|
|||||||
name: user.name ?? undefined,
|
name: user.name ?? undefined,
|
||||||
verified: user.verified,
|
verified: user.verified,
|
||||||
onboardingCompleted: user.onboardingCompleted === true,
|
onboardingCompleted: user.onboardingCompleted === true,
|
||||||
|
taskDigestPreference: user.taskDigestPreference ?? 'both',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
|
import type { TaskDigestPreference } from '../tasks/task-digest.types';
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -8,6 +9,7 @@ export interface AuthUser {
|
|||||||
verificationToken?: string;
|
verificationToken?: string;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
onboardingCompleted: boolean;
|
onboardingCompleted: boolean;
|
||||||
|
taskDigestPreference: TaskDigestPreference;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthTokens {
|
export interface AuthTokens {
|
||||||
@@ -36,6 +38,7 @@ export interface PublicUser {
|
|||||||
name?: string;
|
name?: string;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
onboardingCompleted: boolean;
|
onboardingCompleted: boolean;
|
||||||
|
taskDigestPreference: TaskDigestPreference;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicUserSearchResult {
|
export interface PublicUserSearchResult {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ListTemplateShareEntity } from '../list-templates/list-template-share.e
|
|||||||
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 { UserTaskEntity } from '../tasks/user-task.entity';
|
import { UserTaskEntity } from '../tasks/user-task.entity';
|
||||||
|
import type { TaskDigestPreference } from '../tasks/task-digest.types';
|
||||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
import { RefreshTokenEntity } from './refresh-token.entity';
|
||||||
|
|
||||||
@Entity('users')
|
@Entity('users')
|
||||||
@@ -39,6 +40,15 @@ export class UserEntity {
|
|||||||
@Column({ type: 'boolean', default: false })
|
@Column({ type: 'boolean', default: false })
|
||||||
onboardingCompleted!: boolean;
|
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 })
|
@Index('IDX_users_mcp_api_key_hash', { unique: true })
|
||||||
@Column({ type: 'varchar', length: 64, nullable: true })
|
@Column({ type: 'varchar', length: 64, nullable: true })
|
||||||
mcpApiKeyHash?: string | null;
|
mcpApiKeyHash?: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddTaskDigestPreferences1782000000000 implements MigrationInterface {
|
||||||
|
name = 'AddTaskDigestPreferences1782000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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`',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,11 @@ import { MailerService } from '@nestjs-modules/mailer';
|
|||||||
import { existsSync, readFileSync } from 'fs';
|
import { existsSync, readFileSync } from 'fs';
|
||||||
import { compile, TemplateDelegate } from 'handlebars';
|
import { compile, TemplateDelegate } from 'handlebars';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { ListReminderEmailPayload, SentEmail } from './mail.types';
|
import {
|
||||||
|
ListReminderEmailPayload,
|
||||||
|
SentEmail,
|
||||||
|
TaskDigestEmailPayload,
|
||||||
|
} from './mail.types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MailService {
|
export class MailService {
|
||||||
@@ -12,6 +16,7 @@ export class MailService {
|
|||||||
private readonly sentEmails: SentEmail[] = [];
|
private readonly sentEmails: SentEmail[] = [];
|
||||||
private verificationTemplate?: TemplateDelegate;
|
private verificationTemplate?: TemplateDelegate;
|
||||||
private listReminderTemplate?: TemplateDelegate;
|
private listReminderTemplate?: TemplateDelegate;
|
||||||
|
private taskDigestTemplate?: TemplateDelegate;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@@ -32,7 +37,9 @@ export class MailService {
|
|||||||
this.sentEmails.push(email);
|
this.sentEmails.push(email);
|
||||||
|
|
||||||
if (!this.mailEnabled()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +103,9 @@ export class MailService {
|
|||||||
this.sentEmails.push(email);
|
this.sentEmails.push(email);
|
||||||
|
|
||||||
if (!this.mailEnabled()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +137,82 @@ export class MailService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendTaskDigestEmail(
|
||||||
|
to: string,
|
||||||
|
payload: TaskDigestEmailPayload,
|
||||||
|
): Promise<void> {
|
||||||
|
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<string>('MAIL_FROM_NAME', 'Listify'),
|
||||||
|
clientUrl: this.configService.get<string>(
|
||||||
|
'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[] {
|
getSentEmails(): SentEmail[] {
|
||||||
return [...this.sentEmails];
|
return [...this.sentEmails];
|
||||||
}
|
}
|
||||||
@@ -164,6 +249,34 @@ export class MailService {
|
|||||||
return this.listReminderTemplate(context);
|
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 {
|
private resolveTemplatePath(fileName: string): string {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
join(process.cwd(), 'dist', 'mail', 'templates', fileName),
|
join(process.cwd(), 'dist', 'mail', 'templates', fileName),
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export interface SentEmail {
|
|||||||
listId?: string;
|
listId?: string;
|
||||||
listUrl?: string;
|
listUrl?: string;
|
||||||
openItemTitles?: string[];
|
openItemTitles?: string[];
|
||||||
|
taskDigestDate?: string;
|
||||||
|
taskDigestSlot?: string;
|
||||||
|
taskTitles?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListReminderEmailItem {
|
export interface ListReminderEmailItem {
|
||||||
@@ -20,3 +23,18 @@ export interface ListReminderEmailPayload {
|
|||||||
listUrl: string;
|
listUrl: string;
|
||||||
openItems: ListReminderEmailItem[];
|
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[];
|
||||||
|
}
|
||||||
|
|||||||
78
listify-api/src/mail/templates/task-digest.hbs
Normal file
78
listify-api/src/mail/templates/task-digest.hbs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Listify Tasks</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;background:#f4f7f5;font-family:Arial,Helvetica,sans-serif;color:#17211b;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7f5;padding:24px 12px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:560px;background:#ffffff;border:1px solid #dce5df;border-radius:8px;overflow:hidden;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px 28px 18px;background:#0f5f3d;color:#ffffff;">
|
||||||
|
<div style="font-size:14px;letter-spacing:.08em;text-transform:uppercase;opacity:.85;">{{appName}}</div>
|
||||||
|
<h1 style="margin:10px 0 0;font-size:26px;line-height:1.2;font-weight:700;">{{slotLabel}} fuer deine Tasks</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px;">
|
||||||
|
<p style="margin:0 0 18px;font-size:16px;line-height:1.55;">
|
||||||
|
{{#if displayName}}Hallo {{displayName}}, {{/if}}du hast {{taskCount}} offene Tasks fuer {{dateLabel}} oder frueher.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{#if overdueTaskCount}}
|
||||||
|
<h2 style="margin:22px 0 10px;font-size:18px;line-height:1.3;">Ueberfaellig</h2>
|
||||||
|
<ul style="margin:0 0 18px;padding-left:22px;font-size:16px;line-height:1.55;">
|
||||||
|
{{#each overdueTasks}}
|
||||||
|
<li style="margin:0 0 8px;">
|
||||||
|
<strong>{{title}}</strong>
|
||||||
|
<span style="color:#52645a;"> - faellig am {{dueDate}}</span>
|
||||||
|
{{#if notes}}<div style="color:#52645a;font-size:14px;">{{notes}}</div>{{/if}}
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if todayTaskCount}}
|
||||||
|
<h2 style="margin:22px 0 10px;font-size:18px;line-height:1.3;">Heute</h2>
|
||||||
|
<ul style="margin:0 0 24px;padding-left:22px;font-size:16px;line-height:1.55;">
|
||||||
|
{{#each todayTasks}}
|
||||||
|
<li style="margin:0 0 8px;">
|
||||||
|
<strong>{{title}}</strong>
|
||||||
|
{{#if notes}}<div style="color:#52645a;font-size:14px;">{{notes}}</div>{{/if}}
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" style="margin:24px 0;">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{tasksUrl}}" style="display:inline-block;background:#0f5f3d;color:#ffffff;text-decoration:none;border-radius:6px;padding:14px 20px;font-size:16px;font-weight:700;">
|
||||||
|
Tasks oeffnen
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:0;padding:12px;background:#eef4f0;border:1px solid #dce5df;border-radius:6px;font-size:13px;line-height:1.45;word-break:break-all;">
|
||||||
|
<a href="{{tasksUrl}}" style="color:#0f5f3d;">{{tasksUrl}}</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:18px 28px;background:#f8faf8;border-top:1px solid #e7eee9;color:#6a7b71;font-size:12px;line-height:1.5;">
|
||||||
|
<div>{{appName}} - Listen, Templates und Tagesaufgaben.</div>
|
||||||
|
<div style="margin-top:4px;">© {{currentYear}} {{appName}}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
161
listify-api/src/tasks/task-digest.service.spec.ts
Normal file
161
listify-api/src/tasks/task-digest.service.spec.ts
Normal file
@@ -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<UserEntity>;
|
||||||
|
let tasksRepository: InMemoryRepository<UserTaskEntity>;
|
||||||
|
let mailService: Pick<MailService, 'sendTaskDigestEmail'>;
|
||||||
|
let service: TaskDigestService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
usersRepository = new InMemoryRepository<UserEntity>();
|
||||||
|
tasksRepository = new InMemoryRepository<UserTaskEntity>();
|
||||||
|
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<UserEntity> = {}) {
|
||||||
|
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<UserTaskEntity> = {}) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
230
listify-api/src/tasks/task-digest.service.ts
Normal file
230
listify-api/src/tasks/task-digest.service.ts
Normal file
@@ -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<UserEntity>,
|
||||||
|
@InjectRepository(UserTaskEntity)
|
||||||
|
private readonly tasksRepository: Repository<UserTaskEntity>,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly mailService: MailService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
if (process.env.NODE_ENV === 'test') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalMs = Number(
|
||||||
|
this.configService.get<string>('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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string>(
|
||||||
|
'TASK_DIGEST_TIMEZONE',
|
||||||
|
'Europe/Budapest',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private tasksUrl(): string {
|
||||||
|
const clientUrl = this.configService.get<string>(
|
||||||
|
'CLIENT_URL',
|
||||||
|
'http://localhost:4200',
|
||||||
|
);
|
||||||
|
|
||||||
|
return `${clientUrl.replace(/\/$/, '')}/tasks`;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
listify-api/src/tasks/task-digest.types.ts
Normal file
2
listify-api/src/tasks/task-digest.types.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export type TaskDigestPreference = 'none' | 'morning' | 'both';
|
||||||
|
export type TaskDigestSlot = 'morning' | 'afternoon';
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
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 { 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: [AuditModule, TypeOrmModule.forFeature([UserTaskEntity]), AuthModule],
|
imports: [
|
||||||
|
AuditModule,
|
||||||
|
MailModule,
|
||||||
|
TypeOrmModule.forFeature([UserEntity, UserTaskEntity]),
|
||||||
|
],
|
||||||
controllers: [TasksController],
|
controllers: [TasksController],
|
||||||
providers: [TasksService],
|
providers: [TaskDigestService, TasksService],
|
||||||
exports: [TasksService],
|
exports: [TasksService],
|
||||||
})
|
})
|
||||||
export class TasksModule {}
|
export class TasksModule {}
|
||||||
|
|||||||
@@ -17,6 +17,44 @@
|
|||||||
{{ auth.user()?.onboardingCompleted ? 'Onboarding abgeschlossen' : 'Onboarding offen' }}
|
{{ auth.user()?.onboardingCompleted ? 'Onboarding abgeschlossen' : 'Onboarding offen' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="settings-section" aria-label="Task-Mail Einstellungen">
|
||||||
|
<div class="settings-heading">
|
||||||
|
<mat-icon aria-hidden="true">mark_email_unread</mat-icon>
|
||||||
|
<div>
|
||||||
|
<h2>Task-Mail</h2>
|
||||||
|
<p>Offene Tasks von heute und ueberfaellige Tasks.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Taegliche Benachrichtigung</mat-label>
|
||||||
|
<mat-select
|
||||||
|
[value]="auth.user()?.taskDigestPreference ?? 'both'"
|
||||||
|
[disabled]="savingTaskDigestPreference"
|
||||||
|
(selectionChange)="updateTaskDigestPreference($event.value)"
|
||||||
|
>
|
||||||
|
@for (option of taskDigestPreferenceOptions; track option.value) {
|
||||||
|
<mat-option [value]="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
@for (option of taskDigestPreferenceOptions; track option.value) {
|
||||||
|
@if ((auth.user()?.taskDigestPreference ?? 'both') === option.value) {
|
||||||
|
<p class="settings-description">{{ option.description }}</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (savingTaskDigestPreference) {
|
||||||
|
<div class="saving-row">
|
||||||
|
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||||
|
<span>Speichert...</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
|
|
||||||
<mat-card-actions align="end">
|
<mat-card-actions align="end">
|
||||||
|
|||||||
@@ -30,6 +30,56 @@
|
|||||||
color: var(--mat-sys-primary);
|
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 {
|
mat-card-actions {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
@@ -4,12 +4,24 @@ import { MatButtonModule } from '@angular/material/button';
|
|||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
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 { AuthService } from '../auth/auth.service';
|
||||||
|
import { TaskDigestPreference } from '../auth/auth.models';
|
||||||
import { OnboardingService } from '../onboarding/onboarding.service';
|
import { OnboardingService } from '../onboarding/onboarding.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-account',
|
selector: 'app-account',
|
||||||
imports: [MatButtonModule, MatCardModule, MatIconModule, MatProgressSpinnerModule],
|
imports: [
|
||||||
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
],
|
||||||
templateUrl: './account.component.html',
|
templateUrl: './account.component.html',
|
||||||
styleUrl: './account.component.scss',
|
styleUrl: './account.component.scss',
|
||||||
})
|
})
|
||||||
@@ -17,11 +29,57 @@ export class AccountComponent {
|
|||||||
protected readonly auth = inject(AuthService);
|
protected readonly auth = inject(AuthService);
|
||||||
protected readonly onboarding = inject(OnboardingService);
|
protected readonly onboarding = inject(OnboardingService);
|
||||||
private readonly router = inject(Router);
|
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 {
|
resetOnboarding(): void {
|
||||||
this.onboarding.resetForCurrentUser();
|
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 {
|
logout(): void {
|
||||||
this.auth.logout();
|
this.auth.logout();
|
||||||
void this.router.navigateByUrl('/login');
|
void this.router.navigateByUrl('/login');
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
export type TaskDigestPreference = 'none' | 'morning' | 'both';
|
||||||
|
|
||||||
export interface PublicUser {
|
export interface PublicUser {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
onboardingCompleted: boolean;
|
onboardingCompleted: boolean;
|
||||||
|
taskDigestPreference: TaskDigestPreference;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicUserSearchResult {
|
export interface PublicUserSearchResult {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
RegisterResponse,
|
RegisterResponse,
|
||||||
ResendVerificationResponse,
|
ResendVerificationResponse,
|
||||||
|
TaskDigestPreference,
|
||||||
VerifyEmailResponse,
|
VerifyEmailResponse,
|
||||||
} from './auth.models';
|
} from './auth.models';
|
||||||
|
|
||||||
@@ -64,6 +65,12 @@ export class AuthService {
|
|||||||
.pipe(tap((user) => this.storeUser(user)));
|
.pipe(tap((user) => this.storeUser(user)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateTaskDigestPreference(preference: TaskDigestPreference): Observable<PublicUser> {
|
||||||
|
return this.http
|
||||||
|
.patch<PublicUser>(`${this.apiUrl}/me/task-digest`, { preference })
|
||||||
|
.pipe(tap((user) => this.storeUser(user)));
|
||||||
|
}
|
||||||
|
|
||||||
accessToken(): string | null {
|
accessToken(): string | null {
|
||||||
return this.storage?.getItem(ACCESS_TOKEN_KEY) ?? null;
|
return this.storage?.getItem(ACCESS_TOKEN_KEY) ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user