From f77a592fc8c755145319b18f188e5bb106be8190 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Wed, 17 Jun 2026 10:34:30 +0200 Subject: [PATCH] emails --- listify-api/.env.docker.example | 2 + listify-api/.env.example | 2 + .../1781400000000-AddListReminderAt.ts | 21 +++ .../src/list-templates/list-template.types.ts | 1 + listify-api/src/lists/dto/create-list.dto.ts | 1 + listify-api/src/lists/dto/update-list.dto.ts | 1 + .../src/lists/list-reminder.service.spec.ts | 149 ++++++++++++++++++ .../src/lists/list-reminder.service.ts | 127 +++++++++++++++ listify-api/src/lists/lists.module.ts | 5 +- listify-api/src/lists/lists.service.spec.ts | 30 ++++ listify-api/src/lists/lists.service.ts | 36 ++++- listify-api/src/lists/user-list.entity.ts | 4 + listify-api/src/mail/mail.service.ts | 77 ++++++++- listify-api/src/mail/mail.types.ts | 18 ++- .../src/mail/templates/list-reminder.hbs | 63 ++++++++ .../src/testing/in-memory-repository.ts | 35 +++- .../list-detail/list-detail.component.html | 25 +++ .../list-detail/list-detail.component.ts | 36 ++++- .../src/app/lists/lists.component.html | 6 + listify-client/src/app/lists/lists.models.ts | 3 + .../src/app/templates/templates.models.ts | 1 + 21 files changed, 637 insertions(+), 6 deletions(-) create mode 100644 listify-api/src/database/migrations/1781400000000-AddListReminderAt.ts create mode 100644 listify-api/src/lists/list-reminder.service.spec.ts create mode 100644 listify-api/src/lists/list-reminder.service.ts create mode 100644 listify-api/src/mail/templates/list-reminder.hbs diff --git a/listify-api/.env.docker.example b/listify-api/.env.docker.example index b10d5b4..5671cb8 100644 --- a/listify-api/.env.docker.example +++ b/listify-api/.env.docker.example @@ -29,3 +29,5 @@ SMTP_USER= SMTP_PASSWORD= MAIL_FROM=no-reply@listify.local MAIL_FROM_NAME=Listify + +LIST_REMINDER_POLL_INTERVAL_MS=60000 diff --git a/listify-api/.env.example b/listify-api/.env.example index e474090..fe57fa3 100644 --- a/listify-api/.env.example +++ b/listify-api/.env.example @@ -26,3 +26,5 @@ SMTP_USER= SMTP_PASSWORD= MAIL_FROM=no-reply@listify.local MAIL_FROM_NAME=Listify + +LIST_REMINDER_POLL_INTERVAL_MS=60000 diff --git a/listify-api/src/database/migrations/1781400000000-AddListReminderAt.ts b/listify-api/src/database/migrations/1781400000000-AddListReminderAt.ts new file mode 100644 index 0000000..6d8a7ff --- /dev/null +++ b/listify-api/src/database/migrations/1781400000000-AddListReminderAt.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddListReminderAt1781400000000 implements MigrationInterface { + name = 'AddListReminderAt1781400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE `user_lists` ADD `reminderAt` datetime(3) NULL', + ); + await queryRunner.query( + 'CREATE INDEX `IDX_user_lists_reminder_at` ON `user_lists` (`reminderAt`)', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'DROP INDEX `IDX_user_lists_reminder_at` ON `user_lists`', + ); + await queryRunner.query('ALTER TABLE `user_lists` DROP COLUMN `reminderAt`'); + } +} diff --git a/listify-api/src/list-templates/list-template.types.ts b/listify-api/src/list-templates/list-template.types.ts index 1a5a307..4ce79a2 100644 --- a/listify-api/src/list-templates/list-template.types.ts +++ b/listify-api/src/list-templates/list-template.types.ts @@ -57,6 +57,7 @@ export interface UserList { name: string; description?: string; kind: ListTemplateKind; + reminderAt?: string; items: UserListItem[]; collaborators: UserListCollaborator[]; createdAt: string; diff --git a/listify-api/src/lists/dto/create-list.dto.ts b/listify-api/src/lists/dto/create-list.dto.ts index ece71cd..06957bb 100644 --- a/listify-api/src/lists/dto/create-list.dto.ts +++ b/listify-api/src/lists/dto/create-list.dto.ts @@ -4,4 +4,5 @@ export class CreateListDto { name?: string; description?: string; kind?: ListTemplateKind; + reminderAt?: string | null; } diff --git a/listify-api/src/lists/dto/update-list.dto.ts b/listify-api/src/lists/dto/update-list.dto.ts index cfaa9f4..27d278e 100644 --- a/listify-api/src/lists/dto/update-list.dto.ts +++ b/listify-api/src/lists/dto/update-list.dto.ts @@ -4,4 +4,5 @@ export class UpdateListDto { name?: string; description?: string; kind?: ListTemplateKind; + reminderAt?: string | null; } diff --git a/listify-api/src/lists/list-reminder.service.spec.ts b/listify-api/src/lists/list-reminder.service.spec.ts new file mode 100644 index 0000000..0ebf353 --- /dev/null +++ b/listify-api/src/lists/list-reminder.service.spec.ts @@ -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; + let mailService: Pick; + let listsService: Pick; + let service: ListReminderService; + + beforeEach(() => { + listsRepository = new InMemoryRepository(); + 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 & { + items?: Array<{ title: string; checked: boolean; position: number }>; + }, + ): Promise { + 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; + } +}); diff --git a/listify-api/src/lists/list-reminder.service.ts b/listify-api/src/lists/list-reminder.service.ts new file mode 100644 index 0000000..5ec305d --- /dev/null +++ b/listify-api/src/lists/list-reminder.service.ts @@ -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, + 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('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 { + 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 { + 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 { + list.reminderAt = null; + await this.listsRepository.save(list); + await this.listsService.publishListSnapshot(list.id); + } + + private listUrl(listId: string): string { + const clientUrl = this.configService.get( + 'CLIENT_URL', + 'http://localhost:4200', + ); + + return `${clientUrl.replace(/\/$/, '')}/lists/${listId}`; + } +} diff --git a/listify-api/src/lists/lists.module.ts b/listify-api/src/lists/lists.module.ts index bb87a61..d848f2a 100644 --- a/listify-api/src/lists/lists.module.ts +++ b/listify-api/src/lists/lists.module.ts @@ -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 {} diff --git a/listify-api/src/lists/lists.service.spec.ts b/listify-api/src/lists/lists.service.spec.ts index a981069..740b5f4 100644 --- a/listify-api/src/lists/lists.service.spec.ts +++ b/listify-api/src/lists/lists.service.spec.ts @@ -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', diff --git a/listify-api/src/lists/lists.service.ts b/listify-api/src/lists/lists.service.ts index 0b54665..c5a0609 100644 --- a/listify-api/src/lists/lists.service.ts +++ b/listify-api/src/lists/lists.service.ts @@ -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 { + async publishListSnapshot(listId: string): Promise { 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)), diff --git a/listify-api/src/lists/user-list.entity.ts b/listify-api/src/lists/user-list.entity.ts index ebe9c56..0af3e66 100644 --- a/listify-api/src/lists/user-list.entity.ts +++ b/listify-api/src/lists/user-list.entity.ts @@ -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, diff --git a/listify-api/src/mail/mail.service.ts b/listify-api/src/mail/mail.service.ts index 1bb468f..08eba9e 100644 --- a/listify-api/src/mail/mail.service.ts +++ b/listify-api/src/mail/mail.service.ts @@ -4,13 +4,14 @@ import { MailerService } from '@nestjs-modules/mailer'; import { existsSync, readFileSync } from 'fs'; import { compile, TemplateDelegate } from 'handlebars'; import { join } from 'path'; -import { SentEmail } from './mail.types'; +import { ListReminderEmailPayload, SentEmail } from './mail.types'; @Injectable() export class MailService { private readonly logger = new Logger(MailService.name); private readonly sentEmails: SentEmail[] = []; private verificationTemplate?: TemplateDelegate; + private listReminderTemplate?: TemplateDelegate; constructor( private readonly configService: ConfigService, @@ -70,6 +71,63 @@ export class MailService { } } + async sendListReminderEmail( + to: string, + payload: ListReminderEmailPayload, + ): Promise { + const subject = `Erinnerung: ${payload.listName}`; + const openItemLines = payload.openItems.map((item) => `- ${item.title}`); + const email: SentEmail = { + to, + subject, + text: [ + `Erinnerung fuer deine Liste "${payload.listName}".`, + '', + 'Offene Punkte:', + ...openItemLines, + '', + `Liste oeffnen: ${payload.listUrl}`, + ].join('\n'), + listId: payload.listId, + listUrl: payload.listUrl, + openItemTitles: payload.openItems.map((item) => item.title), + }; + + this.sentEmails.push(email); + + if (!this.mailEnabled()) { + this.logger.log(`List reminder email queued for ${to}. Mail sending is disabled.`); + return; + } + + try { + await this.mailerService.sendMail({ + to, + subject: email.subject, + text: email.text, + html: this.renderListReminderTemplate({ + appName: this.configService.get('MAIL_FROM_NAME', 'Listify'), + clientUrl: this.configService.get( + 'CLIENT_URL', + 'http://localhost:4200', + ), + listName: payload.listName, + listUrl: payload.listUrl, + openItems: payload.openItems, + openItemCount: payload.openItems.length, + currentYear: new Date().getFullYear(), + }), + }); + this.logger.log(`List reminder email sent to ${to}.`); + } catch (error) { + this.logger.error( + `List reminder email could not be sent to ${to}.`, + error instanceof Error ? error.stack : undefined, + ); + throw error; + } + } + getSentEmails(): SentEmail[] { return [...this.sentEmails]; } @@ -89,6 +147,23 @@ export class MailService { return this.verificationTemplate(context); } + private renderListReminderTemplate(context: { + appName: string; + clientUrl: string; + listName: string; + listUrl: string; + openItems: ListReminderEmailPayload['openItems']; + openItemCount: number; + currentYear: number; + }): string { + this.listReminderTemplate ??= compile( + readFileSync(this.resolveTemplatePath('list-reminder.hbs'), 'utf8'), + { strict: true }, + ); + + return this.listReminderTemplate(context); + } + private resolveTemplatePath(fileName: string): string { const candidates = [ join(process.cwd(), 'dist', 'mail', 'templates', fileName), diff --git a/listify-api/src/mail/mail.types.ts b/listify-api/src/mail/mail.types.ts index db14ee3..d4921d1 100644 --- a/listify-api/src/mail/mail.types.ts +++ b/listify-api/src/mail/mail.types.ts @@ -2,5 +2,21 @@ export interface SentEmail { to: string; subject: string; text: string; - verificationUrl: string; + verificationUrl?: string; + listId?: string; + listUrl?: string; + openItemTitles?: string[]; +} + +export interface ListReminderEmailItem { + title: string; + notes?: string; + quantity?: number; +} + +export interface ListReminderEmailPayload { + listId: string; + listName: string; + listUrl: string; + openItems: ListReminderEmailItem[]; } diff --git a/listify-api/src/mail/templates/list-reminder.hbs b/listify-api/src/mail/templates/list-reminder.hbs new file mode 100644 index 0000000..7501344 --- /dev/null +++ b/listify-api/src/mail/templates/list-reminder.hbs @@ -0,0 +1,63 @@ + + + + + + Listify Erinnerung + + + + + + +
+ + + + + + + + + + + + +
+
{{appName}}
+

Erinnerung fuer {{listName}}

+
+

+ In deiner Liste sind noch {{openItemCount}} Punkte offen: +

+ +
    + {{#each openItems}} +
  • + {{title}} + {{#if quantity}} - Menge: {{quantity}}{{/if}} + {{#if notes}}
    {{notes}}
    {{/if}} +
  • + {{/each}} +
+ + + + + +
+ + Liste oeffnen + +
+ +

+ {{listUrl}} +

+
+
{{appName}} - Listen, Templates und gemeinsame Planung.
+
© {{currentYear}} {{appName}}
+
+
+ + diff --git a/listify-api/src/testing/in-memory-repository.ts b/listify-api/src/testing/in-memory-repository.ts index 8954e9c..ba0fe60 100644 --- a/listify-api/src/testing/in-memory-repository.ts +++ b/listify-api/src/testing/in-memory-repository.ts @@ -91,13 +91,34 @@ export class InMemoryRepository { return recordValue === null || recordValue === undefined; } + if (value instanceof FindOperator && value.type === 'lessThanOrEqual') { + const operatorValue = value.value as unknown; + + if (recordValue instanceof Date && operatorValue instanceof Date) { + return recordValue.getTime() <= operatorValue.getTime(); + } + + if ( + (typeof recordValue === 'number' || typeof recordValue === 'string') && + (typeof operatorValue === 'number' || typeof operatorValue === 'string') + ) { + return recordValue <= operatorValue; + } + + return false; + } + return recordValue === value; }); } private applyOrder(records: T[], order: unknown): T[] { const sortedRecords = [...records]; - const typedOrder = order as { name?: 'ASC' | 'DESC'; items?: { position?: 'ASC' | 'DESC' } }; + const typedOrder = order as { + name?: 'ASC' | 'DESC'; + reminderAt?: 'ASC' | 'DESC'; + items?: { position?: 'ASC' | 'DESC' }; + }; if (typedOrder?.name) { sortedRecords.sort((left, right) => { @@ -109,6 +130,18 @@ export class InMemoryRepository { }); } + if (typedOrder?.reminderAt) { + sortedRecords.sort((left, right) => { + const leftDate = (left as Record)['reminderAt']; + const rightDate = (right as Record)['reminderAt']; + const leftTime = leftDate instanceof Date ? leftDate.getTime() : 0; + const rightTime = rightDate instanceof Date ? rightDate.getTime() : 0; + return typedOrder.reminderAt === 'DESC' + ? rightTime - leftTime + : leftTime - rightTime; + }); + } + if (typedOrder?.items?.position) { for (const record of sortedRecords) { const items = (record as { items?: Array<{ position: number }> }).items; diff --git a/listify-client/src/app/lists/list-detail/list-detail.component.html b/listify-client/src/app/lists/list-detail/list-detail.component.html index d0cbc2a..37389b6 100644 --- a/listify-client/src/app/lists/list-detail/list-detail.component.html +++ b/listify-client/src/app/lists/list-detail/list-detail.component.html @@ -99,6 +99,23 @@ + + E-Mail-Erinnerung + + + @if (listForm.controls.reminderAt.value) { + + } + +