This commit is contained in:
Bastian Wagner
2026-06-15 10:10:47 +02:00
parent cb938d3dc8
commit c2d2157de8
8 changed files with 100 additions and 14 deletions

View File

@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddSoftDeleteToListsAndTemplates1781300000000
implements MigrationInterface
{
name = 'AddSoftDeleteToListsAndTemplates1781300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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`');
}
}

View File

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

View File

@@ -7,10 +7,12 @@ import { TemplateSeedEntity } from './template-seed.entity';
describe('ListTemplatesService', () => {
let service: ListTemplatesService;
let templatesRepository: InMemoryRepository<ListTemplateEntity>;
beforeEach(() => {
templatesRepository = new InMemoryRepository<ListTemplateEntity>();
service = new ListTemplatesService(
new InMemoryRepository<ListTemplateEntity>() as never,
templatesRepository as never,
new InMemoryRepository<ListTemplateItemEntity>() as never,
new InMemoryRepository<TemplateSeedEntity>() 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 () => {

View File

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

View File

@@ -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<UserListEntity>;
let usersRepository: InMemoryRepository<UserEntity>;
beforeEach(() => {
listsRepository = new InMemoryRepository<UserListEntity>();
usersRepository = new InMemoryRepository<UserEntity>();
service = new ListsService(
new InMemoryRepository<UserListEntity>() as never,
listsRepository as never,
new InMemoryRepository<UserListItemEntity>() as never,
new InMemoryRepository<UserListShareEntity>() 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 () => {

View File

@@ -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<UserList[]> {
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<UserListEntity> {
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' } },
});

View File

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

View File

@@ -1,3 +1,5 @@
import { FindOperator } from 'typeorm';
type WhereClause<T> = Partial<Record<keyof T, unknown>>;
export class InMemoryRepository<T extends object> {
@@ -83,7 +85,13 @@ export class InMemoryRepository<T extends object> {
}
return Object.entries(where).every(([key, value]) => {
return (record as Record<string, unknown>)[key] === value;
const recordValue = (record as Record<string, unknown>)[key];
if (value instanceof FindOperator && value.type === 'isNull') {
return recordValue === null || recordValue === undefined;
}
return recordValue === value;
});
}