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();
}

View File

@@ -42,10 +42,26 @@
<mat-card-header>
<mat-card-title>Details</mat-card-title>
@if (!isCreateMode()) {
<button mat-stroked-button type="button" (click)="showEditor() ? cancelEditing() : startEditing()">
<mat-icon aria-hidden="true">{{ showEditor() ? 'close' : 'edit' }}</mat-icon>
{{ showEditor() ? 'Abbrechen' : 'Bearbeiten' }}
</button>
<div class="detail-actions">
<button
mat-stroked-button
type="button"
[disabled]="creatingTemplate()"
(click)="createTemplateFromList()"
>
@if (creatingTemplate()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">content_copy</mat-icon>
}
Template
</button>
<button mat-stroked-button type="button" (click)="showEditor() ? cancelEditing() : startEditing()">
<mat-icon aria-hidden="true">{{ showEditor() ? 'close' : 'edit' }}</mat-icon>
{{ showEditor() ? 'Abbrechen' : 'Bearbeiten' }}
</button>
</div>
}
</mat-card-header>

View File

@@ -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();

View File

@@ -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<ListTemplate> {
return this.http.post<ListTemplate>(`${this.apiUrl}/${listId}/template`, {});
}
updateItem(
listId: string,
itemId: string,