emails
This commit is contained in:
@@ -4,4 +4,5 @@ export class CreateListDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
kind?: ListTemplateKind;
|
||||
reminderAt?: string | null;
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ export class UpdateListDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
kind?: ListTemplateKind;
|
||||
reminderAt?: string | null;
|
||||
}
|
||||
|
||||
149
listify-api/src/lists/list-reminder.service.spec.ts
Normal file
149
listify-api/src/lists/list-reminder.service.spec.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
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 { ListReminderService } from './list-reminder.service';
|
||||
import { ListsService } from './lists.service';
|
||||
import { UserListEntity } from './user-list.entity';
|
||||
|
||||
describe('ListReminderService', () => {
|
||||
let listsRepository: InMemoryRepository<UserListEntity>;
|
||||
let mailService: Pick<MailService, 'sendListReminderEmail'>;
|
||||
let listsService: Pick<ListsService, 'publishListSnapshot'>;
|
||||
let service: ListReminderService;
|
||||
|
||||
beforeEach(() => {
|
||||
listsRepository = new InMemoryRepository<UserListEntity>();
|
||||
mailService = {
|
||||
sendListReminderEmail: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
listsService = {
|
||||
publishListSnapshot: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
service = new ListReminderService(
|
||||
listsRepository as never,
|
||||
new ConfigService({ CLIENT_URL: 'http://client.test' }),
|
||||
mailService as MailService,
|
||||
listsService as ListsService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('sends due reminders for open items and clears the reminder', async () => {
|
||||
const list = await saveList({
|
||||
reminderAt: new Date('2026-06-17T08:00:00.000Z'),
|
||||
items: [
|
||||
{ title: 'Offen', checked: false, position: 0 },
|
||||
{ title: 'Erledigt', checked: true, position: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
await service.processDueReminders(new Date('2026-06-17T09:00:00.000Z'));
|
||||
|
||||
expect(mailService.sendListReminderEmail).toHaveBeenCalledWith(
|
||||
'owner@example.com',
|
||||
{
|
||||
listId: list.id,
|
||||
listName: 'Testliste',
|
||||
listUrl: `http://client.test/lists/${list.id}`,
|
||||
openItems: [
|
||||
{
|
||||
title: 'Offen',
|
||||
notes: undefined,
|
||||
quantity: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
expect((await listsRepository.findOne({ where: { id: list.id } }))?.reminderAt)
|
||||
.toBeNull();
|
||||
expect(listsService.publishListSnapshot).toHaveBeenCalledWith(list.id);
|
||||
});
|
||||
|
||||
it('clears due reminders without sending when all items are done', async () => {
|
||||
const list = await saveList({
|
||||
reminderAt: new Date('2026-06-17T08:00:00.000Z'),
|
||||
items: [{ title: 'Fertig', checked: true, position: 0 }],
|
||||
});
|
||||
|
||||
await service.processDueReminders(new Date('2026-06-17T09:00:00.000Z'));
|
||||
|
||||
expect(mailService.sendListReminderEmail).not.toHaveBeenCalled();
|
||||
expect((await listsRepository.findOne({ where: { id: list.id } }))?.reminderAt)
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the reminder when sending fails', async () => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
jest
|
||||
.mocked(mailService.sendListReminderEmail)
|
||||
.mockRejectedValueOnce(new Error('smtp down'));
|
||||
const reminderAt = new Date('2026-06-17T08:00:00.000Z');
|
||||
const list = await saveList({
|
||||
reminderAt,
|
||||
items: [{ title: 'Offen', checked: false, position: 0 }],
|
||||
});
|
||||
|
||||
await service.processDueReminders(new Date('2026-06-17T09:00:00.000Z'));
|
||||
|
||||
expect((await listsRepository.findOne({ where: { id: list.id } }))?.reminderAt)
|
||||
.toBe(reminderAt);
|
||||
expect(listsService.publishListSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores reminders that are not due yet or soft deleted', async () => {
|
||||
await saveList({
|
||||
id: 'future-list',
|
||||
reminderAt: new Date('2026-06-17T10:00:00.000Z'),
|
||||
items: [{ title: 'Spaeter', checked: false, position: 0 }],
|
||||
});
|
||||
await saveList({
|
||||
id: 'deleted-list',
|
||||
deletedAt: new Date('2026-06-17T07:00:00.000Z'),
|
||||
reminderAt: new Date('2026-06-17T08:00:00.000Z'),
|
||||
items: [{ title: 'Archiviert', checked: false, position: 0 }],
|
||||
});
|
||||
|
||||
await service.processDueReminders(new Date('2026-06-17T09:00:00.000Z'));
|
||||
|
||||
expect(mailService.sendListReminderEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
async function saveList(
|
||||
overrides: Partial<UserListEntity> & {
|
||||
items?: Array<{ title: string; checked: boolean; position: number }>;
|
||||
},
|
||||
): Promise<UserListEntity> {
|
||||
const list = {
|
||||
id: overrides.id ?? 'list-1',
|
||||
ownerId: 'owner-1',
|
||||
owner: {
|
||||
id: 'owner-1',
|
||||
email: 'owner@example.com',
|
||||
} as UserEntity,
|
||||
name: 'Testliste',
|
||||
description: null,
|
||||
kind: 'todo',
|
||||
reminderAt: overrides.reminderAt ?? null,
|
||||
deletedAt: overrides.deletedAt ?? null,
|
||||
items: (overrides.items ?? []).map((item, index) => ({
|
||||
id: `item-${index}`,
|
||||
listId: overrides.id ?? 'list-1',
|
||||
title: item.title,
|
||||
checked: item.checked,
|
||||
required: true,
|
||||
position: item.position,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as UserListEntity;
|
||||
|
||||
return (await listsRepository.save(list)) as UserListEntity;
|
||||
}
|
||||
});
|
||||
127
listify-api/src/lists/list-reminder.service.ts
Normal file
127
listify-api/src/lists/list-reminder.service.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
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 { MailService } from '../mail/mail.service';
|
||||
import { UserListEntity } from './user-list.entity';
|
||||
import { ListsService } from './lists.service';
|
||||
|
||||
@Injectable()
|
||||
export class ListReminderService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(ListReminderService.name);
|
||||
private timer?: NodeJS.Timeout;
|
||||
private processing = false;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(UserListEntity)
|
||||
private readonly listsRepository: Repository<UserListEntity>,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly mailService: MailService,
|
||||
private readonly listsService: ListsService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = Number(
|
||||
this.configService.get<string>('LIST_REMINDER_POLL_INTERVAL_MS', '60000'),
|
||||
);
|
||||
|
||||
this.timer = setInterval(
|
||||
() => void this.processDueReminders(),
|
||||
Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 60000,
|
||||
);
|
||||
void this.processDueReminders();
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async processDueReminders(now = new Date()): Promise<void> {
|
||||
if (this.processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.processing = true;
|
||||
|
||||
try {
|
||||
const lists = await this.listsRepository.find({
|
||||
where: {
|
||||
deletedAt: IsNull(),
|
||||
reminderAt: LessThanOrEqual(now),
|
||||
},
|
||||
relations: { owner: true, items: true },
|
||||
order: { reminderAt: 'ASC', items: { position: 'ASC' } },
|
||||
});
|
||||
|
||||
for (const list of lists) {
|
||||
await this.processListReminder(list);
|
||||
}
|
||||
} finally {
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async processListReminder(list: UserListEntity): Promise<void> {
|
||||
const openItems = (list.items ?? [])
|
||||
.filter((item) => !item.checked)
|
||||
.sort((left, right) => left.position - right.position);
|
||||
|
||||
if (openItems.length === 0) {
|
||||
await this.clearReminder(list);
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerEmail = list.owner?.email;
|
||||
|
||||
if (!ownerEmail) {
|
||||
this.logger.warn(`List reminder skipped because owner email is missing: ${list.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.mailService.sendListReminderEmail(ownerEmail, {
|
||||
listId: list.id,
|
||||
listName: list.name,
|
||||
listUrl: this.listUrl(list.id),
|
||||
openItems: openItems.map((item) => ({
|
||||
title: item.title,
|
||||
notes: item.notes ?? undefined,
|
||||
quantity: item.quantity ?? undefined,
|
||||
})),
|
||||
});
|
||||
await this.clearReminder(list);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`List reminder could not be sent for list ${list.id}.`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async clearReminder(list: UserListEntity): Promise<void> {
|
||||
list.reminderAt = null;
|
||||
await this.listsRepository.save(list);
|
||||
await this.listsService.publishListSnapshot(list.id);
|
||||
}
|
||||
|
||||
private listUrl(listId: string): string {
|
||||
const clientUrl = this.configService.get<string>(
|
||||
'CLIENT_URL',
|
||||
'http://localhost:4200',
|
||||
);
|
||||
|
||||
return `${clientUrl.replace(/\/$/, '')}/lists/${listId}`;
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { MailModule } from '../mail/mail.module';
|
||||
import { UserEntity } from '../auth/user.entity';
|
||||
import { ListTemplateEntity } from '../list-templates/list-template.entity';
|
||||
import { ListTemplateItemEntity } from '../list-templates/list-template-item.entity';
|
||||
import { ListsController } from './lists.controller';
|
||||
import { ListRealtimeService } from './list-realtime.service';
|
||||
import { ListReminderService } from './list-reminder.service';
|
||||
import { ListsService } from './lists.service';
|
||||
import { UserListEntity } from './user-list.entity';
|
||||
import { UserListItemEntity } from './user-list-item.entity';
|
||||
@@ -16,6 +18,7 @@ import { UserListShareEntity } from './user-list-share.entity';
|
||||
imports: [
|
||||
AuditModule,
|
||||
AuthModule,
|
||||
MailModule,
|
||||
TypeOrmModule.forFeature([
|
||||
UserEntity,
|
||||
UserListEntity,
|
||||
@@ -26,7 +29,7 @@ import { UserListShareEntity } from './user-list-share.entity';
|
||||
]),
|
||||
],
|
||||
controllers: [ListsController],
|
||||
providers: [ListRealtimeService, ListsService],
|
||||
providers: [ListRealtimeService, ListReminderService, ListsService],
|
||||
exports: [ListRealtimeService, ListsService],
|
||||
})
|
||||
export class ListsModule {}
|
||||
|
||||
@@ -57,12 +57,42 @@ describe('ListsService', () => {
|
||||
|
||||
expect(list.name).toBe('Sommerurlaub 2026');
|
||||
expect(list.kind).toBe('packing');
|
||||
expect(list.reminderAt).toBeUndefined();
|
||||
expect(list.items).toHaveLength(0);
|
||||
expect(list.sourceTemplateId).toBeUndefined();
|
||||
await expect(service.listLists('user-1')).resolves.toHaveLength(1);
|
||||
await expect(service.listLists('user-2')).resolves.toHaveLength(0);
|
||||
});
|
||||
|
||||
it('creates, updates and clears list reminders', async () => {
|
||||
const reminderAt = '2026-06-18T10:30:00.000Z';
|
||||
const list = await service.createList('user-1', {
|
||||
name: 'Reminder Liste',
|
||||
reminderAt,
|
||||
});
|
||||
|
||||
expect(list.reminderAt).toBe(reminderAt);
|
||||
|
||||
const updatedList = await service.updateList('user-1', list.id, {
|
||||
reminderAt: '2026-06-19T08:15:00.000Z',
|
||||
});
|
||||
const clearedList = await service.updateList('user-1', list.id, {
|
||||
reminderAt: null,
|
||||
});
|
||||
|
||||
expect(updatedList.reminderAt).toBe('2026-06-19T08:15:00.000Z');
|
||||
expect(clearedList.reminderAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects invalid list reminder timestamps', async () => {
|
||||
await expect(
|
||||
service.createList('user-1', {
|
||||
name: 'Reminder Liste',
|
||||
reminderAt: 'not-a-date',
|
||||
}),
|
||||
).rejects.toThrow('Reminder time must be an ISO date string.');
|
||||
});
|
||||
|
||||
it('updates and deletes concrete lists', async () => {
|
||||
const list = await service.createList('user-1', {
|
||||
name: 'Todo',
|
||||
|
||||
@@ -91,6 +91,7 @@ export class ListsService {
|
||||
name: this.requireName(createDto.name),
|
||||
description: this.normalizeOptionalText(createDto.description),
|
||||
kind: this.normalizeKind(createDto.kind),
|
||||
reminderAt: this.normalizeReminderAt(createDto.reminderAt) ?? null,
|
||||
deletedAt: null,
|
||||
items: [],
|
||||
});
|
||||
@@ -245,6 +246,10 @@ export class ListsService {
|
||||
list.kind = this.normalizeKind(updateDto.kind);
|
||||
}
|
||||
|
||||
if (updateDto.reminderAt !== undefined) {
|
||||
list.reminderAt = this.normalizeReminderAt(updateDto.reminderAt);
|
||||
}
|
||||
|
||||
const savedList = await this.listsRepository.save(list);
|
||||
|
||||
await this.auditLogService?.record({
|
||||
@@ -719,6 +724,34 @@ export class ListsService {
|
||||
return value;
|
||||
}
|
||||
|
||||
private normalizeReminderAt(value?: string | null): Date | null | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException('Reminder time must be an ISO date string.');
|
||||
}
|
||||
|
||||
const normalizedValue = value.trim();
|
||||
|
||||
if (!normalizedValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reminderAt = new Date(normalizedValue);
|
||||
|
||||
if (Number.isNaN(reminderAt.getTime())) {
|
||||
throw new BadRequestException('Reminder time must be an ISO date string.');
|
||||
}
|
||||
|
||||
return reminderAt;
|
||||
}
|
||||
|
||||
private requireShareUserId(userId?: string): string {
|
||||
const normalizedUserId = userId?.trim();
|
||||
|
||||
@@ -768,7 +801,7 @@ export class ListsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async publishListSnapshot(listId: string): Promise<void> {
|
||||
async publishListSnapshot(listId: string): Promise<void> {
|
||||
if (!this.listRealtimeService) {
|
||||
return;
|
||||
}
|
||||
@@ -804,6 +837,7 @@ export class ListsService {
|
||||
name: list.name,
|
||||
description: list.description ?? undefined,
|
||||
kind: list.kind,
|
||||
reminderAt: list.reminderAt ? this.toIsoString(list.reminderAt) : undefined,
|
||||
items: (list.items ?? [])
|
||||
.sort((left, right) => left.position - right.position)
|
||||
.map((item) => this.toUserListItem(item)),
|
||||
|
||||
@@ -35,6 +35,10 @@ export class UserListEntity {
|
||||
@Column({ type: 'varchar', length: 32, default: 'custom' })
|
||||
kind!: ListTemplateKind;
|
||||
|
||||
@Index('IDX_user_lists_reminder_at')
|
||||
@Column({ type: 'datetime', precision: 3, nullable: true })
|
||||
reminderAt?: Date | null;
|
||||
|
||||
@CreateDateColumn({
|
||||
type: 'datetime',
|
||||
precision: 3,
|
||||
|
||||
Reference in New Issue
Block a user