diff --git a/listify-api/src/database/migrations/1781300000000-AddSoftDeleteToListsAndTemplates.ts b/listify-api/src/database/migrations/1781300000000-AddSoftDeleteToListsAndTemplates.ts new file mode 100644 index 0000000..a7c9457 --- /dev/null +++ b/listify-api/src/database/migrations/1781300000000-AddSoftDeleteToListsAndTemplates.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSoftDeleteToListsAndTemplates1781300000000 + implements MigrationInterface +{ + name = 'AddSoftDeleteToListsAndTemplates1781300000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE `user_lists` ADD `deletedAt` datetime(3) NULL', + ); + await queryRunner.query( + 'ALTER TABLE `list_templates` ADD `deletedAt` datetime(3) NULL', + ); + await queryRunner.query( + 'CREATE INDEX `IDX_user_lists_deleted_at` ON `user_lists` (`deletedAt`)', + ); + await queryRunner.query( + 'CREATE INDEX `IDX_list_templates_deleted_at` ON `list_templates` (`deletedAt`)', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'DROP INDEX `IDX_list_templates_deleted_at` ON `list_templates`', + ); + await queryRunner.query( + 'DROP INDEX `IDX_user_lists_deleted_at` ON `user_lists`', + ); + await queryRunner.query('ALTER TABLE `list_templates` DROP COLUMN `deletedAt`'); + await queryRunner.query('ALTER TABLE `user_lists` DROP COLUMN `deletedAt`'); + } +} diff --git a/listify-api/src/list-templates/list-template.entity.ts b/listify-api/src/list-templates/list-template.entity.ts index 670b4e2..0c81e28 100644 --- a/listify-api/src/list-templates/list-template.entity.ts +++ b/listify-api/src/list-templates/list-template.entity.ts @@ -46,6 +46,10 @@ export class ListTemplateEntity { }) updatedAt!: Date; + @Index('IDX_list_templates_deleted_at') + @Column({ type: 'datetime', precision: 3, nullable: true }) + deletedAt?: Date | null; + @ManyToOne(() => UserEntity, (user) => user.templates, { onDelete: 'CASCADE', }) diff --git a/listify-api/src/list-templates/list-templates.service.spec.ts b/listify-api/src/list-templates/list-templates.service.spec.ts index 0ed18c7..b4484fd 100644 --- a/listify-api/src/list-templates/list-templates.service.spec.ts +++ b/listify-api/src/list-templates/list-templates.service.spec.ts @@ -7,10 +7,12 @@ import { TemplateSeedEntity } from './template-seed.entity'; describe('ListTemplatesService', () => { let service: ListTemplatesService; + let templatesRepository: InMemoryRepository; beforeEach(() => { + templatesRepository = new InMemoryRepository(); service = new ListTemplatesService( - new InMemoryRepository() as never, + templatesRepository as never, new InMemoryRepository() as never, new InMemoryRepository() as never, ); @@ -41,6 +43,17 @@ describe('ListTemplatesService', () => { (await service.listTemplates('user-1')) .some((existingTemplate) => existingTemplate.id === template.id), ).toBe(false); + await expect(service.getTemplate('user-1', template.id)).rejects.toThrow( + NotFoundException, + ); + await expect( + service.updateTemplate('user-1', template.id, { name: 'Wieder da' }), + ).rejects.toThrow(NotFoundException); + expect( + (await templatesRepository.find()).find( + (storedTemplate) => storedTemplate.id === template.id, + )?.deletedAt, + ).toBeInstanceOf(Date); }); it('creates and lists templates for the owning user', async () => { diff --git a/listify-api/src/list-templates/list-templates.service.ts b/listify-api/src/list-templates/list-templates.service.ts index ef715bb..7d5764c 100644 --- a/listify-api/src/list-templates/list-templates.service.ts +++ b/listify-api/src/list-templates/list-templates.service.ts @@ -7,7 +7,7 @@ import { } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { randomUUID } from 'crypto'; -import { Repository } from 'typeorm'; +import { IsNull, Repository } from 'typeorm'; import { AuditLogService } from '../audit/audit-log.service'; import { AddListTemplateItemDto, @@ -51,6 +51,7 @@ export class ListTemplatesService { name: this.requireName(createDto.name), description: this.normalizeOptionalText(createDto.description), kind: this.normalizeKind(createDto.kind), + deletedAt: null, items: this.createTemplateItems(createDto.items ?? []), }); @@ -75,7 +76,7 @@ export class ListTemplatesService { await this.ensureExampleTemplates(ownerId); const templates = await this.templatesRepository.find({ - where: { ownerId }, + where: { ownerId, deletedAt: IsNull() }, relations: { items: true }, order: { name: 'ASC', items: { position: 'ASC' } }, }); @@ -148,7 +149,8 @@ export class ListTemplatesService { kind: template.kind, itemCount: template.items.length, }; - await this.templatesRepository.remove(template); + template.deletedAt = new Date(); + await this.templatesRepository.save(template); await this.auditLogService?.record({ actorUserId: ownerId, @@ -326,7 +328,7 @@ export class ListTemplatesService { templateId: string, ): Promise { const template = await this.templatesRepository.findOne({ - where: { id: templateId }, + where: { id: templateId, deletedAt: IsNull() }, relations: { items: true }, order: { items: { position: 'ASC' } }, }); @@ -397,6 +399,7 @@ export class ListTemplatesService { name: template.name, description: template.description, kind: template.kind, + deletedAt: null, items: this.createTemplateItems(template.items), }), ), diff --git a/listify-api/src/lists/lists.service.spec.ts b/listify-api/src/lists/lists.service.spec.ts index b2e9185..e21727d 100644 --- a/listify-api/src/lists/lists.service.spec.ts +++ b/listify-api/src/lists/lists.service.spec.ts @@ -17,12 +17,14 @@ describe('ListsService', () => { const originalApiKey = process.env.MISTRAL_API_KEY; const originalAgentId = process.env.MISTRAL_AGENT_ID; let service: ListsService; + let listsRepository: InMemoryRepository; let usersRepository: InMemoryRepository; beforeEach(() => { + listsRepository = new InMemoryRepository(); usersRepository = new InMemoryRepository(); service = new ListsService( - new InMemoryRepository() as never, + listsRepository as never, new InMemoryRepository() as never, new InMemoryRepository() as never, usersRepository as never, @@ -67,6 +69,16 @@ describe('ListsService', () => { expect(updatedList.description).toBe('Fokusliste'); expect(deleteResponse.message).toBe('List deleted.'); await expect(service.listLists('user-1')).resolves.toHaveLength(0); + await expect(service.getList('user-1', list.id)).rejects.toThrow( + NotFoundException, + ); + await expect( + service.updateList('user-1', list.id, { name: 'Wieder da' }), + ).rejects.toThrow(NotFoundException); + expect( + (await listsRepository.find()).find((storedList) => storedList.id === list.id) + ?.deletedAt, + ).toBeInstanceOf(Date); }); it('publishes realtime snapshots and deletions for the owning user', async () => { @@ -155,6 +167,12 @@ describe('ListsService', () => { await expect(service.deleteList('user-2', list.id)).rejects.toThrow( ForbiddenException, ); + + await service.deleteList('user-1', list.id); + await expect(service.listLists('user-2')).resolves.toHaveLength(0); + await expect(service.getList('user-2', list.id)).rejects.toThrow( + NotFoundException, + ); }); it('adds, updates, checks and deletes list items', async () => { diff --git a/listify-api/src/lists/lists.service.ts b/listify-api/src/lists/lists.service.ts index c0d454a..2b90329 100644 --- a/listify-api/src/lists/lists.service.ts +++ b/listify-api/src/lists/lists.service.ts @@ -8,7 +8,7 @@ import { } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { randomUUID } from 'crypto'; -import { Repository } from 'typeorm'; +import { IsNull, Repository } from 'typeorm'; import { AuditLogService } from '../audit/audit-log.service'; import { ListTemplate, @@ -78,6 +78,7 @@ export class ListsService { name: this.requireName(createDto.name), description: this.normalizeOptionalText(createDto.description), kind: this.normalizeKind(createDto.kind), + deletedAt: null, items: [], }); @@ -119,6 +120,7 @@ export class ListsService { ? this.normalizeOptionalText(createDto.description) : template.description, kind: template.kind, + deletedAt: null, items: template.items.map((item) => this.listItemsRepository.create({ id: randomUUID(), @@ -157,7 +159,7 @@ export class ListsService { async listLists(ownerId: string): Promise { const ownedLists = await this.listsRepository.find({ - where: { ownerId }, + where: { ownerId, deletedAt: IsNull() }, relations: { items: true, owner: true, shares: { user: true } }, order: { name: 'ASC', items: { position: 'ASC' } }, }); @@ -176,12 +178,12 @@ export class ListsService { const sharedList = share.list ?? (await this.listsRepository.findOne({ - where: { id: share.listId }, + where: { id: share.listId, deletedAt: IsNull() }, relations: { items: true, owner: true, shares: { user: true } }, order: { items: { position: 'ASC' } }, })); - if (sharedList) { + if (sharedList && !sharedList.deletedAt) { await this.hydrateListAccessRelations(sharedList); listsById.set(sharedList.id, sharedList); } @@ -245,7 +247,8 @@ export class ListsService { kind: list.kind, itemCount: list.items.length, }; - await this.listsRepository.remove(list); + list.deletedAt = new Date(); + await this.listsRepository.save(list); await this.auditLogService?.record({ actorUserId: ownerId, @@ -503,7 +506,7 @@ export class ListsService { listId: string, ): Promise { const list = await this.listsRepository.findOne({ - where: { id: listId }, + where: { id: listId, deletedAt: IsNull() }, relations: { items: true, owner: true, shares: { user: true } }, order: { items: { position: 'ASC' } }, }); @@ -693,7 +696,7 @@ export class ListsService { } const list = await this.listsRepository.findOne({ - where: { id: listId }, + where: { id: listId, deletedAt: IsNull() }, relations: { items: true, owner: true, shares: { user: true } }, order: { items: { position: 'ASC' } }, }); diff --git a/listify-api/src/lists/user-list.entity.ts b/listify-api/src/lists/user-list.entity.ts index 924c1a8..ebe9c56 100644 --- a/listify-api/src/lists/user-list.entity.ts +++ b/listify-api/src/lists/user-list.entity.ts @@ -50,6 +50,10 @@ export class UserListEntity { }) updatedAt!: Date; + @Index('IDX_user_lists_deleted_at') + @Column({ type: 'datetime', precision: 3, nullable: true }) + deletedAt?: Date | null; + @ManyToOne(() => UserEntity, (user) => user.lists, { onDelete: 'CASCADE', }) diff --git a/listify-api/src/testing/in-memory-repository.ts b/listify-api/src/testing/in-memory-repository.ts index 65f98a6..8954e9c 100644 --- a/listify-api/src/testing/in-memory-repository.ts +++ b/listify-api/src/testing/in-memory-repository.ts @@ -1,3 +1,5 @@ +import { FindOperator } from 'typeorm'; + type WhereClause = Partial>; export class InMemoryRepository { @@ -83,7 +85,13 @@ export class InMemoryRepository { } return Object.entries(where).every(([key, value]) => { - return (record as Record)[key] === value; + const recordValue = (record as Record)[key]; + + if (value instanceof FindOperator && value.type === 'isNull') { + return recordValue === null || recordValue === undefined; + } + + return recordValue === value; }); }