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;
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@Index('IDX_list_templates_deleted_at')
|
||||||
|
@Column({ type: 'datetime', precision: 3, nullable: true })
|
||||||
|
deletedAt?: Date | null;
|
||||||
|
|
||||||
@ManyToOne(() => UserEntity, (user) => user.templates, {
|
@ManyToOne(() => UserEntity, (user) => user.templates, {
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import { TemplateSeedEntity } from './template-seed.entity';
|
|||||||
|
|
||||||
describe('ListTemplatesService', () => {
|
describe('ListTemplatesService', () => {
|
||||||
let service: ListTemplatesService;
|
let service: ListTemplatesService;
|
||||||
|
let templatesRepository: InMemoryRepository<ListTemplateEntity>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
templatesRepository = new InMemoryRepository<ListTemplateEntity>();
|
||||||
service = new ListTemplatesService(
|
service = new ListTemplatesService(
|
||||||
new InMemoryRepository<ListTemplateEntity>() as never,
|
templatesRepository as never,
|
||||||
new InMemoryRepository<ListTemplateItemEntity>() as never,
|
new InMemoryRepository<ListTemplateItemEntity>() as never,
|
||||||
new InMemoryRepository<TemplateSeedEntity>() as never,
|
new InMemoryRepository<TemplateSeedEntity>() as never,
|
||||||
);
|
);
|
||||||
@@ -41,6 +43,17 @@ describe('ListTemplatesService', () => {
|
|||||||
(await service.listTemplates('user-1'))
|
(await service.listTemplates('user-1'))
|
||||||
.some((existingTemplate) => existingTemplate.id === template.id),
|
.some((existingTemplate) => existingTemplate.id === template.id),
|
||||||
).toBe(false);
|
).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 () => {
|
it('creates and lists templates for the owning user', async () => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { Repository } from 'typeorm';
|
import { IsNull, Repository } from 'typeorm';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import {
|
import {
|
||||||
AddListTemplateItemDto,
|
AddListTemplateItemDto,
|
||||||
@@ -51,6 +51,7 @@ export class ListTemplatesService {
|
|||||||
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),
|
||||||
|
deletedAt: null,
|
||||||
items: this.createTemplateItems(createDto.items ?? []),
|
items: this.createTemplateItems(createDto.items ?? []),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,7 +76,7 @@ export class ListTemplatesService {
|
|||||||
await this.ensureExampleTemplates(ownerId);
|
await this.ensureExampleTemplates(ownerId);
|
||||||
|
|
||||||
const templates = await this.templatesRepository.find({
|
const templates = await this.templatesRepository.find({
|
||||||
where: { ownerId },
|
where: { ownerId, deletedAt: IsNull() },
|
||||||
relations: { items: true },
|
relations: { items: true },
|
||||||
order: { name: 'ASC', items: { position: 'ASC' } },
|
order: { name: 'ASC', items: { position: 'ASC' } },
|
||||||
});
|
});
|
||||||
@@ -148,7 +149,8 @@ export class ListTemplatesService {
|
|||||||
kind: template.kind,
|
kind: template.kind,
|
||||||
itemCount: template.items.length,
|
itemCount: template.items.length,
|
||||||
};
|
};
|
||||||
await this.templatesRepository.remove(template);
|
template.deletedAt = new Date();
|
||||||
|
await this.templatesRepository.save(template);
|
||||||
|
|
||||||
await this.auditLogService?.record({
|
await this.auditLogService?.record({
|
||||||
actorUserId: ownerId,
|
actorUserId: ownerId,
|
||||||
@@ -326,7 +328,7 @@ export class ListTemplatesService {
|
|||||||
templateId: string,
|
templateId: string,
|
||||||
): Promise<ListTemplateEntity> {
|
): Promise<ListTemplateEntity> {
|
||||||
const template = await this.templatesRepository.findOne({
|
const template = await this.templatesRepository.findOne({
|
||||||
where: { id: templateId },
|
where: { id: templateId, deletedAt: IsNull() },
|
||||||
relations: { items: true },
|
relations: { items: true },
|
||||||
order: { items: { position: 'ASC' } },
|
order: { items: { position: 'ASC' } },
|
||||||
});
|
});
|
||||||
@@ -397,6 +399,7 @@ export class ListTemplatesService {
|
|||||||
name: template.name,
|
name: template.name,
|
||||||
description: template.description,
|
description: template.description,
|
||||||
kind: template.kind,
|
kind: template.kind,
|
||||||
|
deletedAt: null,
|
||||||
items: this.createTemplateItems(template.items),
|
items: this.createTemplateItems(template.items),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ describe('ListsService', () => {
|
|||||||
const originalApiKey = process.env.MISTRAL_API_KEY;
|
const originalApiKey = process.env.MISTRAL_API_KEY;
|
||||||
const originalAgentId = process.env.MISTRAL_AGENT_ID;
|
const originalAgentId = process.env.MISTRAL_AGENT_ID;
|
||||||
let service: ListsService;
|
let service: ListsService;
|
||||||
|
let listsRepository: InMemoryRepository<UserListEntity>;
|
||||||
let usersRepository: InMemoryRepository<UserEntity>;
|
let usersRepository: InMemoryRepository<UserEntity>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
listsRepository = new InMemoryRepository<UserListEntity>();
|
||||||
usersRepository = new InMemoryRepository<UserEntity>();
|
usersRepository = new InMemoryRepository<UserEntity>();
|
||||||
service = new ListsService(
|
service = new ListsService(
|
||||||
new InMemoryRepository<UserListEntity>() as never,
|
listsRepository as never,
|
||||||
new InMemoryRepository<UserListItemEntity>() as never,
|
new InMemoryRepository<UserListItemEntity>() as never,
|
||||||
new InMemoryRepository<UserListShareEntity>() as never,
|
new InMemoryRepository<UserListShareEntity>() as never,
|
||||||
usersRepository as never,
|
usersRepository as never,
|
||||||
@@ -67,6 +69,16 @@ describe('ListsService', () => {
|
|||||||
expect(updatedList.description).toBe('Fokusliste');
|
expect(updatedList.description).toBe('Fokusliste');
|
||||||
expect(deleteResponse.message).toBe('List deleted.');
|
expect(deleteResponse.message).toBe('List deleted.');
|
||||||
await expect(service.listLists('user-1')).resolves.toHaveLength(0);
|
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 () => {
|
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(
|
await expect(service.deleteList('user-2', list.id)).rejects.toThrow(
|
||||||
ForbiddenException,
|
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 () => {
|
it('adds, updates, checks and deletes list items', async () => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { Repository } from 'typeorm';
|
import { IsNull, Repository } from 'typeorm';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import {
|
import {
|
||||||
ListTemplate,
|
ListTemplate,
|
||||||
@@ -78,6 +78,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),
|
||||||
|
deletedAt: null,
|
||||||
items: [],
|
items: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,6 +120,7 @@ export class ListsService {
|
|||||||
? this.normalizeOptionalText(createDto.description)
|
? this.normalizeOptionalText(createDto.description)
|
||||||
: template.description,
|
: template.description,
|
||||||
kind: template.kind,
|
kind: template.kind,
|
||||||
|
deletedAt: null,
|
||||||
items: template.items.map((item) =>
|
items: template.items.map((item) =>
|
||||||
this.listItemsRepository.create({
|
this.listItemsRepository.create({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
@@ -157,7 +159,7 @@ export class ListsService {
|
|||||||
|
|
||||||
async listLists(ownerId: string): Promise<UserList[]> {
|
async listLists(ownerId: string): Promise<UserList[]> {
|
||||||
const ownedLists = await this.listsRepository.find({
|
const ownedLists = await this.listsRepository.find({
|
||||||
where: { ownerId },
|
where: { ownerId, deletedAt: IsNull() },
|
||||||
relations: { items: true, owner: true, shares: { user: true } },
|
relations: { items: true, owner: true, shares: { user: true } },
|
||||||
order: { name: 'ASC', items: { position: 'ASC' } },
|
order: { name: 'ASC', items: { position: 'ASC' } },
|
||||||
});
|
});
|
||||||
@@ -176,12 +178,12 @@ export class ListsService {
|
|||||||
const sharedList =
|
const sharedList =
|
||||||
share.list ??
|
share.list ??
|
||||||
(await this.listsRepository.findOne({
|
(await this.listsRepository.findOne({
|
||||||
where: { id: share.listId },
|
where: { id: share.listId, deletedAt: IsNull() },
|
||||||
relations: { items: true, owner: true, shares: { user: true } },
|
relations: { items: true, owner: true, shares: { user: true } },
|
||||||
order: { items: { position: 'ASC' } },
|
order: { items: { position: 'ASC' } },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (sharedList) {
|
if (sharedList && !sharedList.deletedAt) {
|
||||||
await this.hydrateListAccessRelations(sharedList);
|
await this.hydrateListAccessRelations(sharedList);
|
||||||
listsById.set(sharedList.id, sharedList);
|
listsById.set(sharedList.id, sharedList);
|
||||||
}
|
}
|
||||||
@@ -245,7 +247,8 @@ export class ListsService {
|
|||||||
kind: list.kind,
|
kind: list.kind,
|
||||||
itemCount: list.items.length,
|
itemCount: list.items.length,
|
||||||
};
|
};
|
||||||
await this.listsRepository.remove(list);
|
list.deletedAt = new Date();
|
||||||
|
await this.listsRepository.save(list);
|
||||||
|
|
||||||
await this.auditLogService?.record({
|
await this.auditLogService?.record({
|
||||||
actorUserId: ownerId,
|
actorUserId: ownerId,
|
||||||
@@ -503,7 +506,7 @@ export class ListsService {
|
|||||||
listId: string,
|
listId: string,
|
||||||
): Promise<UserListEntity> {
|
): Promise<UserListEntity> {
|
||||||
const list = await this.listsRepository.findOne({
|
const list = await this.listsRepository.findOne({
|
||||||
where: { id: listId },
|
where: { id: listId, deletedAt: IsNull() },
|
||||||
relations: { items: true, owner: true, shares: { user: true } },
|
relations: { items: true, owner: true, shares: { user: true } },
|
||||||
order: { items: { position: 'ASC' } },
|
order: { items: { position: 'ASC' } },
|
||||||
});
|
});
|
||||||
@@ -693,7 +696,7 @@ export class ListsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const list = await this.listsRepository.findOne({
|
const list = await this.listsRepository.findOne({
|
||||||
where: { id: listId },
|
where: { id: listId, deletedAt: IsNull() },
|
||||||
relations: { items: true, owner: true, shares: { user: true } },
|
relations: { items: true, owner: true, shares: { user: true } },
|
||||||
order: { items: { position: 'ASC' } },
|
order: { items: { position: 'ASC' } },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ export class UserListEntity {
|
|||||||
})
|
})
|
||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@Index('IDX_user_lists_deleted_at')
|
||||||
|
@Column({ type: 'datetime', precision: 3, nullable: true })
|
||||||
|
deletedAt?: Date | null;
|
||||||
|
|
||||||
@ManyToOne(() => UserEntity, (user) => user.lists, {
|
@ManyToOne(() => UserEntity, (user) => user.lists, {
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { FindOperator } from 'typeorm';
|
||||||
|
|
||||||
type WhereClause<T> = Partial<Record<keyof T, unknown>>;
|
type WhereClause<T> = Partial<Record<keyof T, unknown>>;
|
||||||
|
|
||||||
export class InMemoryRepository<T extends object> {
|
export class InMemoryRepository<T extends object> {
|
||||||
@@ -83,7 +85,13 @@ export class InMemoryRepository<T extends object> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Object.entries(where).every(([key, value]) => {
|
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