Initial
This commit is contained in:
7
listify-api/src/lists/dto/create-list.dto.ts
Normal file
7
listify-api/src/lists/dto/create-list.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { ListTemplateKind } from '../../list-templates/list-template.types';
|
||||
|
||||
export class CreateListDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
kind?: ListTemplateKind;
|
||||
}
|
||||
14
listify-api/src/lists/dto/list-item.dto.ts
Normal file
14
listify-api/src/lists/dto/list-item.dto.ts
Normal 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;
|
||||
}
|
||||
7
listify-api/src/lists/dto/update-list.dto.ts
Normal file
7
listify-api/src/lists/dto/update-list.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { ListTemplateKind } from '../../list-templates/list-template.types';
|
||||
|
||||
export class UpdateListDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
kind?: ListTemplateKind;
|
||||
}
|
||||
122
listify-api/src/lists/lists.controller.ts
Normal file
122
listify-api/src/lists/lists.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
15
listify-api/src/lists/lists.module.ts
Normal file
15
listify-api/src/lists/lists.module.ts
Normal 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 {}
|
||||
160
listify-api/src/lists/lists.service.spec.ts
Normal file
160
listify-api/src/lists/lists.service.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
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();
|
||||
}
|
||||
}
|
||||
72
listify-api/src/lists/user-list-item.entity.ts
Normal file
72
listify-api/src/lists/user-list-item.entity.ts
Normal 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;
|
||||
}
|
||||
62
listify-api/src/lists/user-list.entity.ts
Normal file
62
listify-api/src/lists/user-list.entity.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user