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, @InjectRepository(UserListItemEntity) private readonly listItemsRepository: Repository, ) {} async createList(ownerId: string, createDto: CreateListDto): Promise { 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 { 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 { 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 { return this.toUserList(await this.findOwnedList(ownerId, listId)); } async updateList( ownerId: string, listId: string, updateDto: UpdateListDto, ): Promise { 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 { 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 { 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 { 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 { 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(); } }