Initial
This commit is contained in:
371
listify-api/src/lists/lists.service.ts
Normal file
371
listify-api/src/lists/lists.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user