templates aus listen erstellen

This commit is contained in:
Bastian Wagner
2026-06-15 10:38:35 +02:00
parent c2d2157de8
commit 8caf207c7b
8 changed files with 243 additions and 4 deletions

View File

@@ -7,6 +7,7 @@ export type AuditAction =
| 'user.token_refreshed'
| 'user.onboarding_updated'
| 'template.created'
| 'template.created_from_list'
| 'template.updated'
| 'template.deleted'
| 'template.item_created'

View File

@@ -126,6 +126,17 @@ export class ListsController {
return this.listsService.suggestItems(this.requireUserId(request), listId);
}
@Post(':listId/template')
createTemplateFromList(
@Req() request: AuthenticatedRequest,
@Param('listId') listId: string,
) {
return this.listsService.createTemplateFromList(
this.requireUserId(request),
listId,
);
}
@Patch(':listId/items/:itemId')
async updateItem(
@Req() request: AuthenticatedRequest,

View File

@@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { UserEntity } from '../auth/user.entity';
import { ListTemplateEntity } from '../list-templates/list-template.entity';
import { ListTemplateItemEntity } from '../list-templates/list-template-item.entity';
import { ListsController } from './lists.controller';
import { ListRealtimeService } from './list-realtime.service';
import { ListsService } from './lists.service';
@@ -19,6 +21,8 @@ import { UserListShareEntity } from './user-list-share.entity';
UserListEntity,
UserListItemEntity,
UserListShareEntity,
ListTemplateEntity,
ListTemplateItemEntity,
]),
],
controllers: [ListsController],

View File

@@ -4,6 +4,8 @@ import {
ServiceUnavailableException,
} from '@nestjs/common';
import { UserEntity } from '../auth/user.entity';
import { ListTemplateEntity } from '../list-templates/list-template.entity';
import { ListTemplateItemEntity } from '../list-templates/list-template-item.entity';
import { ListTemplate } from '../list-templates/list-template.types';
import { InMemoryRepository } from '../testing/in-memory-repository';
import { ListRealtimeService } from './list-realtime.service';
@@ -18,16 +20,24 @@ describe('ListsService', () => {
const originalAgentId = process.env.MISTRAL_AGENT_ID;
let service: ListsService;
let listsRepository: InMemoryRepository<UserListEntity>;
let templatesRepository: InMemoryRepository<ListTemplateEntity>;
let templateItemsRepository: InMemoryRepository<ListTemplateItemEntity>;
let usersRepository: InMemoryRepository<UserEntity>;
beforeEach(() => {
listsRepository = new InMemoryRepository<UserListEntity>();
templatesRepository = new InMemoryRepository<ListTemplateEntity>();
templateItemsRepository = new InMemoryRepository<ListTemplateItemEntity>();
usersRepository = new InMemoryRepository<UserEntity>();
service = new ListsService(
listsRepository as never,
new InMemoryRepository<UserListItemEntity>() as never,
new InMemoryRepository<UserListShareEntity>() as never,
usersRepository as never,
undefined,
undefined,
templatesRepository as never,
templateItemsRepository as never,
);
global.fetch = jest.fn();
});
@@ -255,6 +265,93 @@ describe('ListsService', () => {
await expect(service.listLists('user-1')).resolves.toHaveLength(1);
});
it('creates a template from an accessible list', async () => {
const list = await service.createList('user-1', {
name: 'Sommerurlaub',
description: 'Packliste fuer das Meer',
kind: 'packing',
});
const withFirstItem = await service.addItem('user-1', list.id, {
title: 'Pass',
required: true,
});
const withSecondItem = await service.addItem('user-1', list.id, {
title: 'Sonnencreme',
notes: 'Reisegroesse',
quantity: 2,
required: false,
});
await service.updateItem(
'user-1',
list.id,
withFirstItem.items[0].id,
{ checked: true },
);
const template = await service.createTemplateFromList(
'user-1',
withSecondItem.id,
);
expect(template.ownerId).toBe('user-1');
expect(template.name).toBe('Sommerurlaub');
expect(template.description).toBe('Packliste fuer das Meer');
expect(template.kind).toBe('packing');
expect(template.items).toEqual([
expect.objectContaining({
title: 'Pass',
required: true,
position: 0,
}),
expect.objectContaining({
title: 'Sonnencreme',
notes: 'Reisegroesse',
quantity: 2,
required: false,
position: 1,
}),
]);
expect((template.items[0] as { checked?: unknown }).checked).toBeUndefined();
expect(await templatesRepository.find()).toHaveLength(1);
});
it('lets collaborators create their own template from a shared list', async () => {
await usersRepository.save({
id: 'user-2',
email: 'collaborator@example.com',
passwordHash: 'hash',
verified: true,
onboardingCompleted: false,
createdAt: new Date(),
updatedAt: new Date(),
});
const list = await service.createList('user-1', {
name: 'Team Einkauf',
kind: 'shopping',
});
await service.addItem('user-1', list.id, { title: 'Milch' });
await service.shareList('user-1', list.id, { userId: 'user-2' });
const template = await service.createTemplateFromList('user-2', list.id);
expect(template.ownerId).toBe('user-2');
expect(template.name).toBe('Team Einkauf');
expect(template.items.map((item) => item.title)).toEqual(['Milch']);
});
it('does not create templates from soft-deleted lists', async () => {
const list = await service.createList('user-1', {
name: 'Archiviert',
kind: 'todo',
});
await service.deleteList('user-1', list.id);
await expect(
service.createTemplateFromList('user-1', list.id),
).rejects.toThrow(NotFoundException);
expect(await templatesRepository.find()).toHaveLength(0);
});
it('does not allow users to access lists owned by other users', async () => {
const list = await service.createList('user-1', {
name: 'Private Liste',

View File

@@ -10,6 +10,8 @@ import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { IsNull, Repository } from 'typeorm';
import { AuditLogService } from '../audit/audit-log.service';
import { ListTemplateEntity } from '../list-templates/list-template.entity';
import { ListTemplateItemEntity } from '../list-templates/list-template-item.entity';
import {
ListTemplate,
ListTemplateKind,
@@ -69,6 +71,12 @@ export class ListsService {
private readonly auditLogService?: AuditLogService,
@Optional()
private readonly listRealtimeService?: ListRealtimeService,
@Optional()
@InjectRepository(ListTemplateEntity)
private readonly templatesRepository?: Repository<ListTemplateEntity>,
@Optional()
@InjectRepository(ListTemplateItemEntity)
private readonly templateItemsRepository?: Repository<ListTemplateItemEntity>,
) {}
async createList(ownerId: string, createDto: CreateListDto): Promise<UserList> {
@@ -501,6 +509,56 @@ export class ListsService {
return { suggestions };
}
async createTemplateFromList(
ownerId: string,
listId: string,
): Promise<ListTemplate> {
if (!this.templatesRepository || !this.templateItemsRepository) {
throw new ServiceUnavailableException(
'List template storage is not configured.',
);
}
const list = await this.findAccessibleList(ownerId, listId);
const template = this.templatesRepository.create({
id: randomUUID(),
ownerId,
name: list.name,
description: list.description,
kind: list.kind,
deletedAt: null,
items: list.items
.sort((left, right) => left.position - right.position)
.map((item, index) =>
this.templateItemsRepository!.create({
id: randomUUID(),
title: item.title,
notes: item.notes,
quantity: item.quantity,
required: item.required,
position: index,
}),
),
});
const savedTemplate = await this.templatesRepository.save(template);
await this.auditLogService?.record({
actorUserId: ownerId,
action: 'template.created_from_list',
entityType: 'template',
entityId: savedTemplate.id,
metadata: {
sourceListId: list.id,
sourceListName: list.name,
name: savedTemplate.name,
kind: savedTemplate.kind,
itemCount: savedTemplate.items?.length ?? 0,
},
});
return this.toListTemplate(savedTemplate);
}
private async findAccessibleList(
ownerId: string,
listId: string,
@@ -760,6 +818,30 @@ export class ListsService {
};
}
private toListTemplate(template: ListTemplateEntity): ListTemplate {
return {
id: template.id,
ownerId: template.ownerId,
name: template.name,
description: template.description ?? undefined,
kind: template.kind,
items: (template.items ?? [])
.sort((left, right) => left.position - right.position)
.map((item) => ({
id: item.id,
title: item.title,
notes: item.notes ?? undefined,
quantity: item.quantity ?? undefined,
required: item.required,
position: item.position,
createdAt: this.toIsoString(item.createdAt),
updatedAt: this.toIsoString(item.updatedAt),
})),
createdAt: this.toIsoString(template.createdAt),
updatedAt: this.toIsoString(template.updatedAt),
};
}
private toIsoString(value?: Date): string {
return (value ?? new Date()).toISOString();
}