emails
This commit is contained in:
@@ -29,3 +29,5 @@ SMTP_USER=
|
|||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
MAIL_FROM=no-reply@listify.local
|
MAIL_FROM=no-reply@listify.local
|
||||||
MAIL_FROM_NAME=Listify
|
MAIL_FROM_NAME=Listify
|
||||||
|
|
||||||
|
LIST_REMINDER_POLL_INTERVAL_MS=60000
|
||||||
|
|||||||
@@ -26,3 +26,5 @@ SMTP_USER=
|
|||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
MAIL_FROM=no-reply@listify.local
|
MAIL_FROM=no-reply@listify.local
|
||||||
MAIL_FROM_NAME=Listify
|
MAIL_FROM_NAME=Listify
|
||||||
|
|
||||||
|
LIST_REMINDER_POLL_INTERVAL_MS=60000
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddListReminderAt1781400000000 implements MigrationInterface {
|
||||||
|
name = 'AddListReminderAt1781400000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
'DROP INDEX `IDX_user_lists_reminder_at` ON `user_lists`',
|
||||||
|
);
|
||||||
|
await queryRunner.query('ALTER TABLE `user_lists` DROP COLUMN `reminderAt`');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ export interface UserList {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
kind: ListTemplateKind;
|
kind: ListTemplateKind;
|
||||||
|
reminderAt?: string;
|
||||||
items: UserListItem[];
|
items: UserListItem[];
|
||||||
collaborators: UserListCollaborator[];
|
collaborators: UserListCollaborator[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export class CreateListDto {
|
|||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
kind?: ListTemplateKind;
|
kind?: ListTemplateKind;
|
||||||
|
reminderAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export class UpdateListDto {
|
|||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
kind?: ListTemplateKind;
|
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 { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { MailModule } from '../mail/mail.module';
|
||||||
import { UserEntity } from '../auth/user.entity';
|
import { UserEntity } from '../auth/user.entity';
|
||||||
import { ListTemplateEntity } from '../list-templates/list-template.entity';
|
import { ListTemplateEntity } from '../list-templates/list-template.entity';
|
||||||
import { ListTemplateItemEntity } from '../list-templates/list-template-item.entity';
|
import { ListTemplateItemEntity } from '../list-templates/list-template-item.entity';
|
||||||
import { ListsController } from './lists.controller';
|
import { ListsController } from './lists.controller';
|
||||||
import { ListRealtimeService } from './list-realtime.service';
|
import { ListRealtimeService } from './list-realtime.service';
|
||||||
|
import { ListReminderService } from './list-reminder.service';
|
||||||
import { ListsService } from './lists.service';
|
import { ListsService } from './lists.service';
|
||||||
import { UserListEntity } from './user-list.entity';
|
import { UserListEntity } from './user-list.entity';
|
||||||
import { UserListItemEntity } from './user-list-item.entity';
|
import { UserListItemEntity } from './user-list-item.entity';
|
||||||
@@ -16,6 +18,7 @@ import { UserListShareEntity } from './user-list-share.entity';
|
|||||||
imports: [
|
imports: [
|
||||||
AuditModule,
|
AuditModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
MailModule,
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
UserEntity,
|
UserEntity,
|
||||||
UserListEntity,
|
UserListEntity,
|
||||||
@@ -26,7 +29,7 @@ import { UserListShareEntity } from './user-list-share.entity';
|
|||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
controllers: [ListsController],
|
controllers: [ListsController],
|
||||||
providers: [ListRealtimeService, ListsService],
|
providers: [ListRealtimeService, ListReminderService, ListsService],
|
||||||
exports: [ListRealtimeService, ListsService],
|
exports: [ListRealtimeService, ListsService],
|
||||||
})
|
})
|
||||||
export class ListsModule {}
|
export class ListsModule {}
|
||||||
|
|||||||
@@ -57,12 +57,42 @@ describe('ListsService', () => {
|
|||||||
|
|
||||||
expect(list.name).toBe('Sommerurlaub 2026');
|
expect(list.name).toBe('Sommerurlaub 2026');
|
||||||
expect(list.kind).toBe('packing');
|
expect(list.kind).toBe('packing');
|
||||||
|
expect(list.reminderAt).toBeUndefined();
|
||||||
expect(list.items).toHaveLength(0);
|
expect(list.items).toHaveLength(0);
|
||||||
expect(list.sourceTemplateId).toBeUndefined();
|
expect(list.sourceTemplateId).toBeUndefined();
|
||||||
await expect(service.listLists('user-1')).resolves.toHaveLength(1);
|
await expect(service.listLists('user-1')).resolves.toHaveLength(1);
|
||||||
await expect(service.listLists('user-2')).resolves.toHaveLength(0);
|
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 () => {
|
it('updates and deletes concrete lists', async () => {
|
||||||
const list = await service.createList('user-1', {
|
const list = await service.createList('user-1', {
|
||||||
name: 'Todo',
|
name: 'Todo',
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export class ListsService {
|
|||||||
name: this.requireName(createDto.name),
|
name: this.requireName(createDto.name),
|
||||||
description: this.normalizeOptionalText(createDto.description),
|
description: this.normalizeOptionalText(createDto.description),
|
||||||
kind: this.normalizeKind(createDto.kind),
|
kind: this.normalizeKind(createDto.kind),
|
||||||
|
reminderAt: this.normalizeReminderAt(createDto.reminderAt) ?? null,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
items: [],
|
items: [],
|
||||||
});
|
});
|
||||||
@@ -245,6 +246,10 @@ export class ListsService {
|
|||||||
list.kind = this.normalizeKind(updateDto.kind);
|
list.kind = this.normalizeKind(updateDto.kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (updateDto.reminderAt !== undefined) {
|
||||||
|
list.reminderAt = this.normalizeReminderAt(updateDto.reminderAt);
|
||||||
|
}
|
||||||
|
|
||||||
const savedList = await this.listsRepository.save(list);
|
const savedList = await this.listsRepository.save(list);
|
||||||
|
|
||||||
await this.auditLogService?.record({
|
await this.auditLogService?.record({
|
||||||
@@ -719,6 +724,34 @@ export class ListsService {
|
|||||||
return value;
|
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 {
|
private requireShareUserId(userId?: string): string {
|
||||||
const normalizedUserId = userId?.trim();
|
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) {
|
if (!this.listRealtimeService) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -804,6 +837,7 @@ export class ListsService {
|
|||||||
name: list.name,
|
name: list.name,
|
||||||
description: list.description ?? undefined,
|
description: list.description ?? undefined,
|
||||||
kind: list.kind,
|
kind: list.kind,
|
||||||
|
reminderAt: list.reminderAt ? this.toIsoString(list.reminderAt) : undefined,
|
||||||
items: (list.items ?? [])
|
items: (list.items ?? [])
|
||||||
.sort((left, right) => left.position - right.position)
|
.sort((left, right) => left.position - right.position)
|
||||||
.map((item) => this.toUserListItem(item)),
|
.map((item) => this.toUserListItem(item)),
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ export class UserListEntity {
|
|||||||
@Column({ type: 'varchar', length: 32, default: 'custom' })
|
@Column({ type: 'varchar', length: 32, default: 'custom' })
|
||||||
kind!: ListTemplateKind;
|
kind!: ListTemplateKind;
|
||||||
|
|
||||||
|
@Index('IDX_user_lists_reminder_at')
|
||||||
|
@Column({ type: 'datetime', precision: 3, nullable: true })
|
||||||
|
reminderAt?: Date | null;
|
||||||
|
|
||||||
@CreateDateColumn({
|
@CreateDateColumn({
|
||||||
type: 'datetime',
|
type: 'datetime',
|
||||||
precision: 3,
|
precision: 3,
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import { MailerService } from '@nestjs-modules/mailer';
|
|||||||
import { existsSync, readFileSync } from 'fs';
|
import { existsSync, readFileSync } from 'fs';
|
||||||
import { compile, TemplateDelegate } from 'handlebars';
|
import { compile, TemplateDelegate } from 'handlebars';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { SentEmail } from './mail.types';
|
import { ListReminderEmailPayload, SentEmail } from './mail.types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MailService {
|
export class MailService {
|
||||||
private readonly logger = new Logger(MailService.name);
|
private readonly logger = new Logger(MailService.name);
|
||||||
private readonly sentEmails: SentEmail[] = [];
|
private readonly sentEmails: SentEmail[] = [];
|
||||||
private verificationTemplate?: TemplateDelegate;
|
private verificationTemplate?: TemplateDelegate;
|
||||||
|
private listReminderTemplate?: TemplateDelegate;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@@ -70,6 +71,63 @@ export class MailService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendListReminderEmail(
|
||||||
|
to: string,
|
||||||
|
payload: ListReminderEmailPayload,
|
||||||
|
): Promise<void> {
|
||||||
|
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<string>('MAIL_FROM_NAME', 'Listify'),
|
||||||
|
clientUrl: this.configService.get<string>(
|
||||||
|
'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[] {
|
getSentEmails(): SentEmail[] {
|
||||||
return [...this.sentEmails];
|
return [...this.sentEmails];
|
||||||
}
|
}
|
||||||
@@ -89,6 +147,23 @@ export class MailService {
|
|||||||
return this.verificationTemplate(context);
|
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 {
|
private resolveTemplatePath(fileName: string): string {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
join(process.cwd(), 'dist', 'mail', 'templates', fileName),
|
join(process.cwd(), 'dist', 'mail', 'templates', fileName),
|
||||||
|
|||||||
@@ -2,5 +2,21 @@ export interface SentEmail {
|
|||||||
to: string;
|
to: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
text: 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[];
|
||||||
}
|
}
|
||||||
|
|||||||
63
listify-api/src/mail/templates/list-reminder.hbs
Normal file
63
listify-api/src/mail/templates/list-reminder.hbs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Listify Erinnerung</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;">Erinnerung fuer {{listName}}</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px;">
|
||||||
|
<p style="margin:0 0 18px;font-size:16px;line-height:1.55;">
|
||||||
|
In deiner Liste sind noch {{openItemCount}} Punkte offen:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul style="margin:0 0 24px;padding-left:22px;font-size:16px;line-height:1.55;">
|
||||||
|
{{#each openItems}}
|
||||||
|
<li style="margin:0 0 8px;">
|
||||||
|
<strong>{{title}}</strong>
|
||||||
|
{{#if quantity}}<span style="color:#52645a;"> - Menge: {{quantity}}</span>{{/if}}
|
||||||
|
{{#if notes}}<div style="color:#52645a;font-size:14px;">{{notes}}</div>{{/if}}
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" style="margin:24px 0;">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{listUrl}}" style="display:inline-block;background:#0f5f3d;color:#ffffff;text-decoration:none;border-radius:6px;padding:14px 20px;font-size:16px;font-weight:700;">
|
||||||
|
Liste 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="{{listUrl}}" style="color:#0f5f3d;">{{listUrl}}</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 gemeinsame Planung.</div>
|
||||||
|
<div style="margin-top:4px;">© {{currentYear}} {{appName}}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -91,13 +91,34 @@ export class InMemoryRepository<T extends object> {
|
|||||||
return recordValue === null || recordValue === undefined;
|
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;
|
return recordValue === value;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyOrder(records: T[], order: unknown): T[] {
|
private applyOrder(records: T[], order: unknown): T[] {
|
||||||
const sortedRecords = [...records];
|
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) {
|
if (typedOrder?.name) {
|
||||||
sortedRecords.sort((left, right) => {
|
sortedRecords.sort((left, right) => {
|
||||||
@@ -109,6 +130,18 @@ export class InMemoryRepository<T extends object> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typedOrder?.reminderAt) {
|
||||||
|
sortedRecords.sort((left, right) => {
|
||||||
|
const leftDate = (left as Record<string, unknown>)['reminderAt'];
|
||||||
|
const rightDate = (right as Record<string, unknown>)['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) {
|
if (typedOrder?.items?.position) {
|
||||||
for (const record of sortedRecords) {
|
for (const record of sortedRecords) {
|
||||||
const items = (record as { items?: Array<{ position: number }> }).items;
|
const items = (record as { items?: Array<{ position: number }> }).items;
|
||||||
|
|||||||
@@ -99,6 +99,23 @@
|
|||||||
<textarea matInput formControlName="description" rows="4"></textarea>
|
<textarea matInput formControlName="description" rows="4"></textarea>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>E-Mail-Erinnerung</mat-label>
|
||||||
|
<input matInput type="datetime-local" formControlName="reminderAt" />
|
||||||
|
<mat-icon matPrefix aria-hidden="true">notifications</mat-icon>
|
||||||
|
@if (listForm.controls.reminderAt.value) {
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
matSuffix
|
||||||
|
type="button"
|
||||||
|
aria-label="Erinnerung entfernen"
|
||||||
|
(click)="clearReminder()"
|
||||||
|
>
|
||||||
|
<mat-icon aria-hidden="true">close</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
<div class="list-form-actions" [class.create-actions]="isCreateMode()">
|
<div class="list-form-actions" [class.create-actions]="isCreateMode()">
|
||||||
<button mat-flat-button type="submit" [disabled]="saving() || creatingWithAi()">
|
<button mat-flat-button type="submit" [disabled]="saving() || creatingWithAi()">
|
||||||
@if (saving()) {
|
@if (saving()) {
|
||||||
@@ -129,6 +146,14 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<div class="list-summary">
|
<div class="list-summary">
|
||||||
<p>{{ list()?.description || 'Keine Beschreibung hinterlegt.' }}</p>
|
<p>{{ list()?.description || 'Keine Beschreibung hinterlegt.' }}</p>
|
||||||
|
@if (list()?.reminderAt) {
|
||||||
|
<div class="inline-empty">
|
||||||
|
<mat-icon aria-hidden="true">notifications</mat-icon>
|
||||||
|
<span>
|
||||||
|
Erinnerung am {{ list()!.reminderAt | date: 'dd.MM.yyyy, HH:mm' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export class ListDetailComponent implements OnInit {
|
|||||||
protected readonly listForm = this.formBuilder.group({
|
protected readonly listForm = this.formBuilder.group({
|
||||||
name: ['', [Validators.required]],
|
name: ['', [Validators.required]],
|
||||||
description: [''],
|
description: [''],
|
||||||
|
reminderAt: [''],
|
||||||
});
|
});
|
||||||
|
|
||||||
protected readonly itemForm = this.formBuilder.group({
|
protected readonly itemForm = this.formBuilder.group({
|
||||||
@@ -118,7 +119,7 @@ export class ListDetailComponent implements OnInit {
|
|||||||
if (this.isCreateMode()) {
|
if (this.isCreateMode()) {
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.editing.set(true);
|
this.editing.set(true);
|
||||||
this.listForm.reset({ name: '', description: '' });
|
this.listForm.reset({ name: '', description: '', reminderAt: '' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,6 +421,7 @@ export class ListDetailComponent implements OnInit {
|
|||||||
this.listForm.reset({
|
this.listForm.reset({
|
||||||
name: list.name,
|
name: list.name,
|
||||||
description: list.description ?? '',
|
description: list.description ?? '',
|
||||||
|
reminderAt: this.toDatetimeLocalValue(list.reminderAt),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,6 +546,7 @@ export class ListDetailComponent implements OnInit {
|
|||||||
this.listForm.reset({
|
this.listForm.reset({
|
||||||
name: list.name,
|
name: list.name,
|
||||||
description: list.description ?? '',
|
description: list.description ?? '',
|
||||||
|
reminderAt: this.toDatetimeLocalValue(list.reminderAt),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -562,9 +565,40 @@ export class ListDetailComponent implements OnInit {
|
|||||||
return {
|
return {
|
||||||
name: formValue.name.trim(),
|
name: formValue.name.trim(),
|
||||||
description: formValue.description.trim() || undefined,
|
description: formValue.description.trim() || undefined,
|
||||||
|
reminderAt: this.datetimeLocalToIso(formValue.reminderAt),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected clearReminder(): void {
|
||||||
|
this.listForm.controls.reminderAt.setValue('');
|
||||||
|
}
|
||||||
|
|
||||||
|
private datetimeLocalToIso(value: string): string | null {
|
||||||
|
const normalizedValue = value.trim();
|
||||||
|
|
||||||
|
if (!normalizedValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(normalizedValue);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDatetimeLocalValue(value?: string | null): string {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const timezoneOffsetMs = date.getTimezoneOffset() * 60 * 1000;
|
||||||
|
return new Date(date.getTime() - timezoneOffsetMs).toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
private uncheckedFirst(items: UserListItem[]): UserListItem[] {
|
private uncheckedFirst(items: UserListItem[]): UserListItem[] {
|
||||||
return items
|
return items
|
||||||
.map((item, index) => ({ item, index }))
|
.map((item, index) => ({ item, index }))
|
||||||
|
|||||||
@@ -144,6 +144,12 @@
|
|||||||
<mat-icon aria-hidden="true">schedule</mat-icon>
|
<mat-icon aria-hidden="true">schedule</mat-icon>
|
||||||
{{ list.updatedAt | date: 'dd.MM.yyyy' }}
|
{{ list.updatedAt | date: 'dd.MM.yyyy' }}
|
||||||
</span>
|
</span>
|
||||||
|
@if (list.reminderAt) {
|
||||||
|
<span>
|
||||||
|
<mat-icon aria-hidden="true">notifications</mat-icon>
|
||||||
|
{{ list.reminderAt | date: 'dd.MM.yyyy, HH:mm' }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
@if (list.collaborators.length > 0) {
|
@if (list.collaborators.length > 0) {
|
||||||
<span>
|
<span>
|
||||||
<mat-icon aria-hidden="true">group</mat-icon>
|
<mat-icon aria-hidden="true">group</mat-icon>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface UserList {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
kind: ListTemplateKind;
|
kind: ListTemplateKind;
|
||||||
|
reminderAt?: string | null;
|
||||||
items: UserListItem[];
|
items: UserListItem[];
|
||||||
collaborators: UserListCollaborator[];
|
collaborators: UserListCollaborator[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -45,12 +46,14 @@ export interface CreateListRequest {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
kind?: ListTemplateKind;
|
kind?: ListTemplateKind;
|
||||||
|
reminderAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateListRequest {
|
export interface UpdateListRequest {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
kind?: ListTemplateKind;
|
kind?: ListTemplateKind;
|
||||||
|
reminderAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddListItemRequest {
|
export interface AddListItemRequest {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface UserList {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
kind: ListTemplateKind;
|
kind: ListTemplateKind;
|
||||||
|
reminderAt?: string | null;
|
||||||
items: UserListItem[];
|
items: UserListItem[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user