delete
This commit is contained in:
@@ -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`');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' } },
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user