mails
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 { 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<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[] {
|
||||
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),
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
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 { 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 {}
|
||||
|
||||
Reference in New Issue
Block a user