templates aus listen erstellen
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user