From 8caf207c7b9bba6da10c1f3f589055b1cc0651fb Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Mon, 15 Jun 2026 10:38:35 +0200 Subject: [PATCH] templates aus listen erstellen --- listify-api/src/audit/audit-log.types.ts | 1 + listify-api/src/lists/lists.controller.ts | 11 +++ listify-api/src/lists/lists.module.ts | 4 + listify-api/src/lists/lists.service.spec.ts | 97 +++++++++++++++++++ listify-api/src/lists/lists.service.ts | 82 ++++++++++++++++ .../list-detail/list-detail.component.html | 24 ++++- .../list-detail/list-detail.component.ts | 23 +++++ listify-client/src/app/lists/lists.service.ts | 5 + 8 files changed, 243 insertions(+), 4 deletions(-) diff --git a/listify-api/src/audit/audit-log.types.ts b/listify-api/src/audit/audit-log.types.ts index abdc5e1..d23cd9d 100644 --- a/listify-api/src/audit/audit-log.types.ts +++ b/listify-api/src/audit/audit-log.types.ts @@ -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' diff --git a/listify-api/src/lists/lists.controller.ts b/listify-api/src/lists/lists.controller.ts index e13d991..7b90242 100644 --- a/listify-api/src/lists/lists.controller.ts +++ b/listify-api/src/lists/lists.controller.ts @@ -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, diff --git a/listify-api/src/lists/lists.module.ts b/listify-api/src/lists/lists.module.ts index cf8dd7f..bb87a61 100644 --- a/listify-api/src/lists/lists.module.ts +++ b/listify-api/src/lists/lists.module.ts @@ -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], diff --git a/listify-api/src/lists/lists.service.spec.ts b/listify-api/src/lists/lists.service.spec.ts index e21727d..5688131 100644 --- a/listify-api/src/lists/lists.service.spec.ts +++ b/listify-api/src/lists/lists.service.spec.ts @@ -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; + let templatesRepository: InMemoryRepository; + let templateItemsRepository: InMemoryRepository; let usersRepository: InMemoryRepository; beforeEach(() => { listsRepository = new InMemoryRepository(); + templatesRepository = new InMemoryRepository(); + templateItemsRepository = new InMemoryRepository(); usersRepository = new InMemoryRepository(); service = new ListsService( listsRepository as never, new InMemoryRepository() as never, new InMemoryRepository() 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', diff --git a/listify-api/src/lists/lists.service.ts b/listify-api/src/lists/lists.service.ts index 2b90329..84eae23 100644 --- a/listify-api/src/lists/lists.service.ts +++ b/listify-api/src/lists/lists.service.ts @@ -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, + @Optional() + @InjectRepository(ListTemplateItemEntity) + private readonly templateItemsRepository?: Repository, ) {} async createList(ownerId: string, createDto: CreateListDto): Promise { @@ -501,6 +509,56 @@ export class ListsService { return { suggestions }; } + async createTemplateFromList( + ownerId: string, + listId: string, + ): Promise { + 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(); } diff --git a/listify-client/src/app/lists/list-detail/list-detail.component.html b/listify-client/src/app/lists/list-detail/list-detail.component.html index dd16919..d4756c3 100644 --- a/listify-client/src/app/lists/list-detail/list-detail.component.html +++ b/listify-client/src/app/lists/list-detail/list-detail.component.html @@ -42,10 +42,26 @@ Details @if (!isCreateMode()) { - +
+ + + +
}
diff --git a/listify-client/src/app/lists/list-detail/list-detail.component.ts b/listify-client/src/app/lists/list-detail/list-detail.component.ts index cb2ab4d..4d5488c 100644 --- a/listify-client/src/app/lists/list-detail/list-detail.component.ts +++ b/listify-client/src/app/lists/list-detail/list-detail.component.ts @@ -58,6 +58,7 @@ export class ListDetailComponent implements OnInit { protected readonly isCreateMode = signal(false); protected readonly loading = signal(true); protected readonly saving = signal(false); + protected readonly creatingTemplate = signal(false); protected readonly editing = signal(false); protected readonly addingItem = signal(false); protected readonly loadingSuggestions = signal(false); @@ -210,6 +211,28 @@ export class ListDetailComponent implements OnInit { }); } + protected createTemplateFromList(): void { + const listId = this.listId(); + + if (!listId || this.creatingTemplate()) { + return; + } + + this.creatingTemplate.set(true); + this.listsService + .createTemplateFromList(listId) + .pipe(finalize(() => this.creatingTemplate.set(false))) + .subscribe({ + next: (template) => { + this.snackBar.open('Template erstellt.', 'OK', { duration: 2500 }); + void this.router.navigate(['/templates', template.id]); + }, + error: (error: unknown) => { + this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 }); + }, + }); + } + protected loadSuggestions(): void { const listId = this.listId(); diff --git a/listify-client/src/app/lists/lists.service.ts b/listify-client/src/app/lists/lists.service.ts index 8d16586..b2d4b15 100644 --- a/listify-client/src/app/lists/lists.service.ts +++ b/listify-client/src/app/lists/lists.service.ts @@ -9,6 +9,7 @@ import { UpdateListRequest, UserList, } from './lists.models'; +import { ListTemplate } from '../templates/templates.models'; @Injectable({ providedIn: 'root' }) export class ListsService { @@ -50,6 +51,10 @@ export class ListsService { ); } + createTemplateFromList(listId: string): Observable { + return this.http.post(`${this.apiUrl}/${listId}/template`, {}); + } + updateItem( listId: string, itemId: string,