This commit is contained in:
Bastian Wagner
2026-06-29 14:34:34 +02:00
parent ecb902565d
commit 9a721f9e86
18 changed files with 884 additions and 7 deletions

View File

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

View File

@@ -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,
);
}
}

View File

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

View File

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

View File

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

View File

@@ -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`',
);
}
}
}

View File

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

View File

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

View 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;">&copy; {{currentYear}} {{appName}}</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View 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);
}
});

View 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`;
}
}

View File

@@ -0,0 +1,2 @@
export type TaskDigestPreference = 'none' | 'morning' | 'both';
export type TaskDigestSlot = 'morning' | 'afternoon';

View File

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