This commit is contained in:
Bastian Wagner
2026-06-09 09:45:33 +02:00
commit 537c7cbbee
124 changed files with 27283 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
import { ListTemplateKind } from '../../list-templates/list-template.types';
export class CreateListDto {
name?: string;
description?: string;
kind?: ListTemplateKind;
}

View File

@@ -0,0 +1,14 @@
export class AddListItemDto {
title?: string;
notes?: string;
quantity?: number;
required?: boolean;
}
export class UpdateListItemDto {
title?: string;
notes?: string;
quantity?: number;
required?: boolean;
checked?: boolean;
}

View File

@@ -0,0 +1,7 @@
import { ListTemplateKind } from '../../list-templates/list-template.types';
export class UpdateListDto {
name?: string;
description?: string;
kind?: ListTemplateKind;
}

View File

@@ -0,0 +1,122 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Req,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AuthService } from '../auth/auth.service';
import { CreateListDto } from './dto/create-list.dto';
import { AddListItemDto, UpdateListItemDto } from './dto/list-item.dto';
import { UpdateListDto } from './dto/update-list.dto';
import { ListsService } from './lists.service';
import type { AuthenticatedRequest } from '../auth/auth.types';
@Controller('lists')
@UseGuards(JwtAuthGuard)
export class ListsController {
constructor(
private readonly authService: AuthService,
private readonly listsService: ListsService,
) {}
@Post()
createList(
@Req() request: AuthenticatedRequest,
@Body() createDto: CreateListDto,
) {
return this.listsService.createList(this.requireUserId(request), createDto);
}
@Get()
listLists(@Req() request: AuthenticatedRequest) {
return this.listsService.listLists(this.requireUserId(request));
}
@Get(':listId')
getList(
@Req() request: AuthenticatedRequest,
@Param('listId') listId: string,
) {
return this.listsService.getList(this.requireUserId(request), listId);
}
@Patch(':listId')
updateList(
@Req() request: AuthenticatedRequest,
@Param('listId') listId: string,
@Body() updateDto: UpdateListDto,
) {
return this.listsService.updateList(
this.requireUserId(request),
listId,
updateDto,
);
}
@Delete(':listId')
deleteList(
@Req() request: AuthenticatedRequest,
@Param('listId') listId: string,
) {
return this.listsService.deleteList(this.requireUserId(request), listId);
}
@Post(':listId/items')
addItem(
@Req() request: AuthenticatedRequest,
@Param('listId') listId: string,
@Body() addDto: AddListItemDto,
) {
return this.listsService.addItem(
this.requireUserId(request),
listId,
addDto,
);
}
@Patch(':listId/items/:itemId')
async updateItem(
@Req() request: AuthenticatedRequest,
@Param('listId') listId: string,
@Param('itemId') itemId: string,
@Body() updateDto: UpdateListItemDto,
) {
const userId = this.requireUserId(request);
return this.listsService.updateItem(
userId,
listId,
itemId,
updateDto,
await this.authService.getUserDisplayName(userId),
);
}
@Delete(':listId/items/:itemId')
deleteItem(
@Req() request: AuthenticatedRequest,
@Param('listId') listId: string,
@Param('itemId') itemId: string,
) {
return this.listsService.deleteItem(
this.requireUserId(request),
listId,
itemId,
);
}
private requireUserId(request: AuthenticatedRequest): string {
if (!request.user?.sub) {
throw new UnauthorizedException('Authenticated user is required.');
}
return request.user.sub;
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from '../auth/auth.module';
import { ListsController } from './lists.controller';
import { ListsService } from './lists.service';
import { UserListEntity } from './user-list.entity';
import { UserListItemEntity } from './user-list-item.entity';
@Module({
imports: [AuthModule, TypeOrmModule.forFeature([UserListEntity, UserListItemEntity])],
controllers: [ListsController],
providers: [ListsService],
exports: [ListsService],
})
export class ListsModule {}

View File

@@ -0,0 +1,160 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { ListTemplate } from '../list-templates/list-template.types';
import { InMemoryRepository } from '../testing/in-memory-repository';
import { ListsService } from './lists.service';
import { UserListEntity } from './user-list.entity';
import { UserListItemEntity } from './user-list-item.entity';
describe('ListsService', () => {
let service: ListsService;
beforeEach(() => {
service = new ListsService(
new InMemoryRepository<UserListEntity>() as never,
new InMemoryRepository<UserListItemEntity>() as never,
);
});
it('creates and lists concrete lists for the owning user', async () => {
const list = await service.createList('user-1', {
name: 'Sommerurlaub 2026',
description: 'Konkrete Packliste',
kind: 'packing',
});
expect(list.name).toBe('Sommerurlaub 2026');
expect(list.kind).toBe('packing');
expect(list.items).toHaveLength(0);
expect(list.sourceTemplateId).toBeUndefined();
await expect(service.listLists('user-1')).resolves.toHaveLength(1);
await expect(service.listLists('user-2')).resolves.toHaveLength(0);
});
it('updates and deletes concrete lists', async () => {
const list = await service.createList('user-1', {
name: 'Todo',
kind: 'todo',
});
const updatedList = await service.updateList('user-1', list.id, {
name: 'Wochenaufgaben',
description: 'Fokusliste',
});
const deleteResponse = await service.deleteList('user-1', list.id);
expect(updatedList.name).toBe('Wochenaufgaben');
expect(updatedList.description).toBe('Fokusliste');
expect(deleteResponse.message).toBe('List deleted.');
await expect(service.listLists('user-1')).resolves.toHaveLength(0);
});
it('adds, updates, checks and deletes list items', async () => {
const list = await service.createList('user-1', {
name: 'Einkauf',
kind: 'shopping',
});
const withFirstItem = await service.addItem('user-1', list.id, {
title: 'Milch',
quantity: 1,
});
const withSecondItem = await service.addItem('user-1', list.id, {
title: 'Brot',
});
const updatedList = await service.updateItem(
'user-1',
list.id,
withFirstItem.items[0].id,
{
title: 'Hafermilch',
quantity: 2,
checked: true,
required: false,
},
);
const remainingList = await service.deleteItem(
'user-1',
list.id,
withSecondItem.items[0].id,
);
expect(withSecondItem.items).toHaveLength(2);
expect(updatedList.items[0].title).toBe('Hafermilch');
expect(updatedList.items[0].quantity).toBe(2);
expect(updatedList.items[0].checked).toBe(true);
expect(updatedList.items[0].required).toBe(false);
expect(remainingList.items).toHaveLength(1);
expect(remainingList.items[0].title).toBe('Brot');
expect(remainingList.items[0].position).toBe(0);
});
it('creates a concrete list from a template', async () => {
const template: ListTemplate = {
id: 'template-1',
ownerId: 'user-1',
name: 'Urlaub',
kind: 'packing',
items: [
{
id: 'template-item-1',
title: 'Pass',
required: true,
position: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: 'template-item-2',
title: 'Tickets',
required: true,
position: 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const list = await service.createListFromTemplate('user-1', template, {
name: 'Sommerurlaub 2026',
});
expect(list.name).toBe('Sommerurlaub 2026');
expect(list.sourceTemplateId).toBe(template.id);
expect(list.items).toHaveLength(2);
expect(list.items[0].checked).toBe(false);
expect(list.items[0].sourceTemplateItemId).toBe(template.items[0].id);
await expect(service.listLists('user-1')).resolves.toHaveLength(1);
});
it('does not allow users to access lists owned by other users', async () => {
const list = await service.createList('user-1', {
name: 'Private Liste',
});
await expect(service.getList('user-2', list.id)).rejects.toThrow(
ForbiddenException,
);
await expect(
service.updateList('user-2', list.id, { name: 'Fremd' }),
).rejects.toThrow(ForbiddenException);
});
it('rejects invalid input and missing resources', async () => {
const list = await service.createList('user-1', { name: 'Liste' });
await expect(service.createList('user-1', { name: ' ' })).rejects.toThrow(
'List name is required.',
);
await expect(
service.addItem('user-1', list.id, { title: '', quantity: 1 }),
).rejects.toThrow('List item title is required.');
await expect(
service.addItem('user-1', list.id, { title: 'Milch', quantity: 0 }),
).rejects.toThrow('Quantity must be greater than zero.');
await expect(service.getList('user-1', 'missing')).rejects.toThrow(
NotFoundException,
);
});
});

View File

@@ -0,0 +1,371 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { Repository } from 'typeorm';
import {
ListTemplate,
ListTemplateKind,
UserList,
UserListItem,
} from '../list-templates/list-template.types';
import { CreateListFromTemplateDto } from '../list-templates/dto/create-list-from-template.dto';
import { AddListItemDto, UpdateListItemDto } from './dto/list-item.dto';
import { CreateListDto } from './dto/create-list.dto';
import { UpdateListDto } from './dto/update-list.dto';
import { UserListEntity } from './user-list.entity';
import { UserListItemEntity } from './user-list-item.entity';
@Injectable()
export class ListsService {
constructor(
@InjectRepository(UserListEntity)
private readonly listsRepository: Repository<UserListEntity>,
@InjectRepository(UserListItemEntity)
private readonly listItemsRepository: Repository<UserListItemEntity>,
) {}
async createList(ownerId: string, createDto: CreateListDto): Promise<UserList> {
const list = this.listsRepository.create({
id: randomUUID(),
ownerId,
name: this.requireName(createDto.name),
description: this.normalizeOptionalText(createDto.description),
kind: this.normalizeKind(createDto.kind),
items: [],
});
return this.toUserList(await this.listsRepository.save(list));
}
async createListFromTemplate(
ownerId: string,
template: ListTemplate,
createDto: CreateListFromTemplateDto = {},
): Promise<UserList> {
if (template.ownerId !== ownerId) {
throw new ForbiddenException('List template belongs to another user.');
}
const list = this.listsRepository.create({
id: randomUUID(),
ownerId,
sourceTemplateId: template.id,
name: this.normalizeDerivedListName(createDto.name, template.name),
description:
createDto.description !== undefined
? this.normalizeOptionalText(createDto.description)
: template.description,
kind: template.kind,
items: template.items.map((item) =>
this.listItemsRepository.create({
id: randomUUID(),
sourceTemplateItemId: item.id,
title: item.title,
notes: item.notes,
quantity: item.quantity,
required: item.required,
checked: false,
position: item.position,
}),
),
});
return this.toUserList(await this.listsRepository.save(list));
}
async listLists(ownerId: string): Promise<UserList[]> {
const lists = await this.listsRepository.find({
where: { ownerId },
relations: { items: true },
order: { name: 'ASC', items: { position: 'ASC' } },
});
return lists.map((list) => this.toUserList(list));
}
async getList(ownerId: string, listId: string): Promise<UserList> {
return this.toUserList(await this.findOwnedList(ownerId, listId));
}
async updateList(
ownerId: string,
listId: string,
updateDto: UpdateListDto,
): Promise<UserList> {
const list = await this.findOwnedList(ownerId, listId);
if (updateDto.name !== undefined) {
list.name = this.requireName(updateDto.name);
}
if (updateDto.description !== undefined) {
list.description = this.normalizeOptionalText(updateDto.description);
}
if (updateDto.kind !== undefined) {
list.kind = this.normalizeKind(updateDto.kind);
}
return this.toUserList(await this.listsRepository.save(list));
}
async deleteList(ownerId: string, listId: string): Promise<{ message: string }> {
const list = await this.findOwnedList(ownerId, listId);
await this.listsRepository.remove(list);
return { message: 'List deleted.' };
}
async addItem(
ownerId: string,
listId: string,
addDto: AddListItemDto,
): Promise<UserList> {
const list = await this.findOwnedList(ownerId, listId);
const item = this.createListItem(addDto, list.items.length);
item.listId = list.id;
const savedItem = await this.listItemsRepository.save(item);
list.items.push(savedItem);
await this.listsRepository.save(list);
return this.getList(ownerId, listId);
}
async updateItem(
ownerId: string,
listId: string,
itemId: string,
updateDto: UpdateListItemDto,
actorName?: string,
): Promise<UserList> {
const list = await this.findOwnedList(ownerId, listId);
const item = this.findListItem(list, itemId);
if (updateDto.title !== undefined) {
item.title = this.requireItemTitle(updateDto.title);
}
if (updateDto.notes !== undefined) {
item.notes = this.normalizeOptionalText(updateDto.notes);
}
if (updateDto.quantity !== undefined) {
item.quantity = this.normalizeQuantity(updateDto.quantity);
}
if (updateDto.required !== undefined) {
item.required = this.normalizeBoolean(updateDto.required, 'required');
}
if (updateDto.checked !== undefined) {
item.checked = this.normalizeBoolean(updateDto.checked, 'checked');
if (item.checked) {
item.checkedAt = new Date();
item.checkedByUserId = ownerId;
item.checkedByName = actorName ?? ownerId;
} else {
item.checkedAt = null;
item.checkedByUserId = null;
item.checkedByName = null;
}
}
await this.listItemsRepository.save(item);
await this.listsRepository.save(list);
return this.getList(ownerId, listId);
}
async deleteItem(
ownerId: string,
listId: string,
itemId: string,
): Promise<UserList> {
const list = await this.findOwnedList(ownerId, listId);
const itemIndex = list.items.findIndex((item) => item.id === itemId);
if (itemIndex === -1) {
throw new NotFoundException('List item was not found.');
}
const [itemToDelete] = list.items.splice(itemIndex, 1);
await this.listItemsRepository.remove(itemToDelete);
list.items.forEach((item, index) => {
item.position = index;
});
await this.listItemsRepository.save(list.items);
await this.listsRepository.save(list);
return this.getList(ownerId, listId);
}
private async findOwnedList(
ownerId: string,
listId: string,
): Promise<UserListEntity> {
const list = await this.listsRepository.findOne({
where: { id: listId },
relations: { items: true },
order: { items: { position: 'ASC' } },
});
if (!list) {
throw new NotFoundException('List was not found.');
}
if (list.ownerId !== ownerId) {
throw new ForbiddenException('List belongs to another user.');
}
list.items = list.items ?? [];
return list;
}
private findListItem(
list: UserListEntity,
itemId: string,
): UserListItemEntity {
const item = list.items.find((listItem) => listItem.id === itemId);
if (!item) {
throw new NotFoundException('List item was not found.');
}
return item;
}
private createListItem(
itemDto: AddListItemDto,
position: number,
): UserListItemEntity {
return this.listItemsRepository.create({
id: randomUUID(),
title: this.requireItemTitle(itemDto.title),
notes: this.normalizeOptionalText(itemDto.notes),
quantity: this.normalizeQuantity(itemDto.quantity),
required:
itemDto.required === undefined
? true
: this.normalizeBoolean(itemDto.required, 'required'),
checked: false,
position,
});
}
private normalizeKind(kind?: ListTemplateKind): ListTemplateKind {
if (kind === undefined) {
return 'custom';
}
if (
kind !== 'packing' &&
kind !== 'shopping' &&
kind !== 'todo' &&
kind !== 'custom'
) {
throw new BadRequestException('List kind is invalid.');
}
return kind;
}
private requireName(name?: string): string {
const normalizedName = name?.trim();
if (!normalizedName) {
throw new BadRequestException('List name is required.');
}
return normalizedName;
}
private requireItemTitle(title?: string): string {
const normalizedTitle = title?.trim();
if (!normalizedTitle) {
throw new BadRequestException('List item title is required.');
}
return normalizedTitle;
}
private normalizeDerivedListName(name: string | undefined, fallback: string) {
const normalizedName = name?.trim();
return normalizedName || fallback;
}
private normalizeOptionalText(value?: string): string | undefined {
const normalizedValue = value?.trim();
return normalizedValue || undefined;
}
private normalizeQuantity(quantity?: number): number | undefined {
if (quantity === undefined) {
return undefined;
}
if (
typeof quantity !== 'number' ||
!Number.isFinite(quantity) ||
quantity <= 0
) {
throw new BadRequestException('Quantity must be greater than zero.');
}
return quantity;
}
private normalizeBoolean(value: boolean, fieldName: string): boolean {
if (typeof value !== 'boolean') {
throw new BadRequestException(`${fieldName} must be a boolean.`);
}
return value;
}
private toUserList(list: UserListEntity): UserList {
return {
id: list.id,
ownerId: list.ownerId,
sourceTemplateId: list.sourceTemplateId ?? undefined,
name: list.name,
description: list.description ?? undefined,
kind: list.kind,
items: (list.items ?? [])
.sort((left, right) => left.position - right.position)
.map((item) => this.toUserListItem(item)),
createdAt: this.toIsoString(list.createdAt),
updatedAt: this.toIsoString(list.updatedAt),
};
}
private toUserListItem(item: UserListItemEntity): UserListItem {
return {
id: item.id,
sourceTemplateItemId: item.sourceTemplateItemId ?? undefined,
title: item.title,
notes: item.notes ?? undefined,
quantity: item.quantity ?? undefined,
required: item.required,
checked: item.checked,
checkedAt: item.checkedAt ? this.toIsoString(item.checkedAt) : undefined,
checkedByUserId: item.checkedByUserId ?? undefined,
checkedByName: item.checkedByName ?? undefined,
position: item.position,
createdAt: this.toIsoString(item.createdAt),
updatedAt: this.toIsoString(item.updatedAt),
};
}
private toIsoString(value?: Date): string {
return (value ?? new Date()).toISOString();
}
}

View File

@@ -0,0 +1,72 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { UserListEntity } from './user-list.entity';
@Entity('user_list_items')
export class UserListItemEntity {
@PrimaryColumn({ type: 'varchar', length: 36 })
id!: string;
@Index()
@Column({ type: 'varchar', length: 36 })
listId!: string;
@Column({ type: 'varchar', length: 36, nullable: true })
sourceTemplateItemId?: string | null;
@Column({ type: 'varchar', length: 220 })
title!: string;
@Column({ type: 'text', nullable: true })
notes?: string | null;
@Column({ type: 'float', nullable: true })
quantity?: number | null;
@Column({ type: 'boolean', default: true })
required!: boolean;
@Column({ type: 'boolean', default: false })
checked!: boolean;
@Column({ type: 'datetime', precision: 3, nullable: true })
checkedAt?: Date | null;
@Column({ type: 'varchar', length: 36, nullable: true })
checkedByUserId?: string | null;
@Column({ type: 'varchar', length: 320, nullable: true })
checkedByName?: string | null;
@Column({ type: 'int' })
position!: number;
@CreateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
})
createdAt!: Date;
@UpdateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
onUpdate: 'CURRENT_TIMESTAMP(3)',
})
updatedAt!: Date;
@ManyToOne(() => UserListEntity, (list) => list.items, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'listId' })
list?: UserListEntity;
}

View File

@@ -0,0 +1,62 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { UserEntity } from '../auth/user.entity';
import { UserListItemEntity } from './user-list-item.entity';
import type { ListTemplateKind } from '../list-templates/list-template.types';
@Entity('user_lists')
export class UserListEntity {
@PrimaryColumn({ type: 'varchar', length: 36 })
id!: string;
@Index()
@Column({ type: 'varchar', length: 36 })
ownerId!: string;
@Column({ type: 'varchar', length: 36, nullable: true })
sourceTemplateId?: string | null;
@Column({ type: 'varchar', length: 160 })
name!: string;
@Column({ type: 'text', nullable: true })
description?: string | null;
@Column({ type: 'varchar', length: 32, default: 'custom' })
kind!: ListTemplateKind;
@CreateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
})
createdAt!: Date;
@UpdateDateColumn({
type: 'datetime',
precision: 3,
default: () => 'CURRENT_TIMESTAMP(3)',
onUpdate: 'CURRENT_TIMESTAMP(3)',
})
updatedAt!: Date;
@ManyToOne(() => UserEntity, (user) => user.lists, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'ownerId' })
owner?: UserEntity;
@OneToMany(() => UserListItemEntity, (item) => item.list, {
cascade: ['insert', 'update'],
})
items!: UserListItemEntity[];
}