templates aus listen erstellen
This commit is contained in:
@@ -7,6 +7,7 @@ export type AuditAction =
|
|||||||
| 'user.token_refreshed'
|
| 'user.token_refreshed'
|
||||||
| 'user.onboarding_updated'
|
| 'user.onboarding_updated'
|
||||||
| 'template.created'
|
| 'template.created'
|
||||||
|
| 'template.created_from_list'
|
||||||
| 'template.updated'
|
| 'template.updated'
|
||||||
| 'template.deleted'
|
| 'template.deleted'
|
||||||
| 'template.item_created'
|
| 'template.item_created'
|
||||||
|
|||||||
@@ -126,6 +126,17 @@ export class ListsController {
|
|||||||
return this.listsService.suggestItems(this.requireUserId(request), listId);
|
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')
|
@Patch(':listId/items/:itemId')
|
||||||
async updateItem(
|
async updateItem(
|
||||||
@Req() request: AuthenticatedRequest,
|
@Req() request: AuthenticatedRequest,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { UserEntity } from '../auth/user.entity';
|
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 { ListsController } from './lists.controller';
|
||||||
import { ListRealtimeService } from './list-realtime.service';
|
import { ListRealtimeService } from './list-realtime.service';
|
||||||
import { ListsService } from './lists.service';
|
import { ListsService } from './lists.service';
|
||||||
@@ -19,6 +21,8 @@ import { UserListShareEntity } from './user-list-share.entity';
|
|||||||
UserListEntity,
|
UserListEntity,
|
||||||
UserListItemEntity,
|
UserListItemEntity,
|
||||||
UserListShareEntity,
|
UserListShareEntity,
|
||||||
|
ListTemplateEntity,
|
||||||
|
ListTemplateItemEntity,
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
controllers: [ListsController],
|
controllers: [ListsController],
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { UserEntity } from '../auth/user.entity';
|
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 { ListTemplate } from '../list-templates/list-template.types';
|
||||||
import { InMemoryRepository } from '../testing/in-memory-repository';
|
import { InMemoryRepository } from '../testing/in-memory-repository';
|
||||||
import { ListRealtimeService } from './list-realtime.service';
|
import { ListRealtimeService } from './list-realtime.service';
|
||||||
@@ -18,16 +20,24 @@ describe('ListsService', () => {
|
|||||||
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 listsRepository: InMemoryRepository<UserListEntity>;
|
||||||
|
let templatesRepository: InMemoryRepository<ListTemplateEntity>;
|
||||||
|
let templateItemsRepository: InMemoryRepository<ListTemplateItemEntity>;
|
||||||
let usersRepository: InMemoryRepository<UserEntity>;
|
let usersRepository: InMemoryRepository<UserEntity>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
listsRepository = new InMemoryRepository<UserListEntity>();
|
listsRepository = new InMemoryRepository<UserListEntity>();
|
||||||
|
templatesRepository = new InMemoryRepository<ListTemplateEntity>();
|
||||||
|
templateItemsRepository = new InMemoryRepository<ListTemplateItemEntity>();
|
||||||
usersRepository = new InMemoryRepository<UserEntity>();
|
usersRepository = new InMemoryRepository<UserEntity>();
|
||||||
service = new ListsService(
|
service = new ListsService(
|
||||||
listsRepository 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,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
templatesRepository as never,
|
||||||
|
templateItemsRepository as never,
|
||||||
);
|
);
|
||||||
global.fetch = jest.fn();
|
global.fetch = jest.fn();
|
||||||
});
|
});
|
||||||
@@ -255,6 +265,93 @@ describe('ListsService', () => {
|
|||||||
await expect(service.listLists('user-1')).resolves.toHaveLength(1);
|
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 () => {
|
it('does not allow users to access lists owned by other users', async () => {
|
||||||
const list = await service.createList('user-1', {
|
const list = await service.createList('user-1', {
|
||||||
name: 'Private Liste',
|
name: 'Private Liste',
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { IsNull, Repository } from 'typeorm';
|
import { IsNull, Repository } from 'typeorm';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
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 {
|
import {
|
||||||
ListTemplate,
|
ListTemplate,
|
||||||
ListTemplateKind,
|
ListTemplateKind,
|
||||||
@@ -69,6 +71,12 @@ export class ListsService {
|
|||||||
private readonly auditLogService?: AuditLogService,
|
private readonly auditLogService?: AuditLogService,
|
||||||
@Optional()
|
@Optional()
|
||||||
private readonly listRealtimeService?: ListRealtimeService,
|
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> {
|
async createList(ownerId: string, createDto: CreateListDto): Promise<UserList> {
|
||||||
@@ -501,6 +509,56 @@ export class ListsService {
|
|||||||
return { suggestions };
|
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(
|
private async findAccessibleList(
|
||||||
ownerId: string,
|
ownerId: string,
|
||||||
listId: 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 {
|
private toIsoString(value?: Date): string {
|
||||||
return (value ?? new Date()).toISOString();
|
return (value ?? new Date()).toISOString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,10 +42,26 @@
|
|||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
<mat-card-title>Details</mat-card-title>
|
<mat-card-title>Details</mat-card-title>
|
||||||
@if (!isCreateMode()) {
|
@if (!isCreateMode()) {
|
||||||
<button mat-stroked-button type="button" (click)="showEditor() ? cancelEditing() : startEditing()">
|
<div class="detail-actions">
|
||||||
<mat-icon aria-hidden="true">{{ showEditor() ? 'close' : 'edit' }}</mat-icon>
|
<button
|
||||||
{{ showEditor() ? 'Abbrechen' : 'Bearbeiten' }}
|
mat-stroked-button
|
||||||
</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>
|
</mat-card-header>
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export class ListDetailComponent implements OnInit {
|
|||||||
protected readonly isCreateMode = signal(false);
|
protected readonly isCreateMode = signal(false);
|
||||||
protected readonly loading = signal(true);
|
protected readonly loading = signal(true);
|
||||||
protected readonly saving = signal(false);
|
protected readonly saving = signal(false);
|
||||||
|
protected readonly creatingTemplate = signal(false);
|
||||||
protected readonly editing = signal(false);
|
protected readonly editing = signal(false);
|
||||||
protected readonly addingItem = signal(false);
|
protected readonly addingItem = signal(false);
|
||||||
protected readonly loadingSuggestions = 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 {
|
protected loadSuggestions(): void {
|
||||||
const listId = this.listId();
|
const listId = this.listId();
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
UpdateListRequest,
|
UpdateListRequest,
|
||||||
UserList,
|
UserList,
|
||||||
} from './lists.models';
|
} from './lists.models';
|
||||||
|
import { ListTemplate } from '../templates/templates.models';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ListsService {
|
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(
|
updateItem(
|
||||||
listId: string,
|
listId: string,
|
||||||
itemId: string,
|
itemId: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user