lots more item apis

This commit is contained in:
Evert Prants 2023-01-13 21:42:21 +02:00
parent 3d7671f979
commit f342995e89
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
13 changed files with 492 additions and 69 deletions

View File

@ -37,11 +37,16 @@ import { AppStorageService } from './app-storage.service';
import {
StorageAddExistingItemRequestDto,
StorageAddItemRequestDto,
StorageItemUpdateRequestDto,
StorageStoredItemTransactionRequestDto,
StorageStoredItemUpdateRequestDto,
} from './dto/storage-add-item-request.dto';
import { StorageItemRequestQueryDto } from './dto/storage-item-request.dto';
import {
StorageItemResponseDto,
StorageItemSearchResponseDto,
StorageStoredItemResponseDto,
StorageTransactionResponseDto,
} from './dto/storage-item-response.dto';
import {
StorageCreateRequestDto,
@ -64,6 +69,28 @@ import { StorageSetResponseDto } from './dto/storage-set-response.dto';
export class AppStorageController {
constructor(private readonly service: AppStorageService) {}
@Get('storages/:storageId')
@ApiParam({ name: 'storageId', description: 'Storage ID' })
@ApiOperation({ summary: 'Get storage by ID' })
@ApiOkResponse({ type: StorageResponseDto })
async getStorage(
@CurrentStorage() storage: Storage,
): Promise<StorageResponseDto> {
return this.service.getStorageWithItems(storage);
}
@Patch('storages/:storageId')
@ApiParam({ name: 'storageId', description: 'Storage ID' })
@ApiBody({ type: StorageUpdateRequestDto })
@ApiOperation({ summary: 'Update storage by ID' })
@ApiOkResponse({ type: StorageResponseDto })
async updateStorage(
@CurrentStorage() storage: Storage,
@Body() body: StorageUpdateRequestDto,
): Promise<StorageResponseDto> {
return this.service.updateStorage(storage, body);
}
@UseGuards(StorageSetGuard)
@Get('set/:storageSetId')
@ApiParam({ name: 'storageSetId', description: 'Storage set ID' })
@ -174,7 +201,7 @@ export class AppStorageController {
@Body() body: StorageAddItemRequestDto,
@CurrentStorage() storage: Storage,
) {
return;
return this.service.createNewItem(user, storage, body);
}
@Post('item/:storageId/:itemId')
@ -189,28 +216,62 @@ export class AppStorageController {
@Body() body: StorageAddExistingItemRequestDto,
@CurrentStorage() storage: Storage,
) {
return;
return this.service.addExistingItemToStorage(user, storage, itemId, body);
}
@Get(':storageId')
@ApiParam({ name: 'storageId', description: 'Storage ID' })
@ApiOperation({ summary: 'Get storage by ID' })
@ApiOkResponse({ type: StorageResponseDto })
async getStorage(
@Patch('item/:storageId/:storedItemId')
@ApiParam({ name: 'storedItemId', description: 'Stored Item ID' })
@ApiOperation({ summary: 'Update a stored items details' })
@ApiBody({ type: StorageStoredItemUpdateRequestDto })
@ApiOkResponse({ type: StorageStoredItemResponseDto })
async updateStoredItem(
@Param('storedItemId', ParseIntPipe) storedItemId: number,
@Body() body: StorageStoredItemUpdateRequestDto,
@CurrentStorage() storage: Storage,
): Promise<StorageResponseDto> {
return this.service.formatStorageNoItems(storage);
) {
return this.service.updateStoredItemDetails(storage, storedItemId, body);
}
@Patch(':storageId')
@ApiParam({ name: 'storageId', description: 'Storage ID' })
@ApiBody({ type: StorageUpdateRequestDto })
@ApiOperation({ summary: 'Update storage by ID' })
@ApiOkResponse({ type: StorageResponseDto })
async updateStorage(
@Post('item/:storageId/:storedItemId/transaction')
@ApiParam({ name: 'storedItemId', description: 'Stored Item ID' })
@ApiOperation({ summary: 'Create a new stored item transaction' })
@ApiBody({ type: StorageStoredItemTransactionRequestDto })
@ApiOkResponse({ type: StorageTransactionResponseDto })
async createStoredItemTransaction(
@LoggedInUser() user: User,
@Param('storedItemId', ParseIntPipe) storedItemId: number,
@Body() body: StorageStoredItemTransactionRequestDto,
@CurrentStorage() storage: Storage,
@Body() body: StorageUpdateRequestDto,
): Promise<StorageResponseDto> {
return this.service.updateStorage(storage, body);
) {
return this.service.createStoredItemTransaction(
user,
storage,
storedItemId,
body,
);
}
@Get('item/:storageId/:storedItemId')
@ApiParam({ name: 'storedItemId', description: 'Stored Item ID' })
@ApiOperation({ summary: 'Get a stored items details' })
@ApiOkResponse({ type: StorageStoredItemResponseDto })
async getStoredItem(
@Param('storedItemId', ParseIntPipe) storedItemId: number,
@CurrentStorage() storage: Storage,
) {
return this.service.getStoredItemDetails(storage, storedItemId);
}
@Patch('item/:itemId')
@ApiParam({ name: 'itemId', description: 'Item ID' })
@ApiBody({ type: StorageItemUpdateRequestDto })
@ApiOperation({ summary: 'Update an item owned by the current user' })
@ApiOkResponse({ type: StorageItemResponseDto })
async updateItem(
@Param('itemId', ParseIntPipe) itemId: number,
@LoggedInUser() user: User,
@Body() body: StorageItemUpdateRequestDto,
) {
return this.service.updateOwnedItem(user, itemId, body);
}
}

View File

@ -1,14 +1,31 @@
import { Injectable } from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import omit from 'lodash.omit';
import pick from 'lodash.pick';
import { BuildingService } from 'src/objects/building/building.service';
import { Room } from 'src/objects/building/entities/room.entity';
import { StoredItemTransaction } from 'src/objects/storage/entities/item-transaction.entity';
import { Item } from 'src/objects/storage/entities/item.entity';
import { StorageSet } from 'src/objects/storage/entities/storage-set.entity';
import { Storage } from 'src/objects/storage/entities/storage.entity';
import { StoredItem } from 'src/objects/storage/entities/stored-item.entity';
import { TransactionType } from 'src/objects/storage/enums/transaction-type.enum';
import { StorageService } from 'src/objects/storage/storage.service';
import { User } from 'src/objects/user/user.entity';
import {
StorageAddExistingItemRequestDto,
StorageAddItemRequestDto,
StorageItemUpdateRequestDto,
StorageStoredItemRequestDto,
StorageStoredItemTransactionRequestDto,
StorageStoredItemUpdateRequestDto,
} from './dto/storage-add-item-request.dto';
import { StorageItemRequestQueryDto } from './dto/storage-item-request.dto';
import { StorageItemSearchResponseDto } from './dto/storage-item-response.dto';
import {
StorageItemResponseDto,
StorageItemSearchResponseDto,
StorageStoredItemResponseDto,
StorageTransactionResponseDto,
} from './dto/storage-item-response.dto';
import {
StorageCreateRequestDto,
StorageUpdateRequestDto,
@ -116,6 +133,188 @@ export class AppStorageService {
return responses;
}
async createStoredItem(
user: User,
item: Item,
storage: Storage,
transactionInfo: StorageStoredItemTransactionRequestDto,
additionalInfo?: StorageStoredItemRequestDto,
) {
// Create stored item
let storedItem = new StoredItem();
storedItem.addedBy = user;
storedItem.item = item;
storedItem.storage = storage;
additionalInfo && Object.assign(storedItem, additionalInfo);
storedItem = await this.storageService.saveStoredItem(storedItem);
// Create transaction
let transaction = new StoredItemTransaction();
transaction.actor = user;
transaction.storedItem = storedItem;
Object.assign(transaction, transactionInfo);
transaction = await this.storageService.saveStoredItemTransaction(
transaction,
);
storedItem.transactions = [transaction];
return new StorageStoredItemResponseDto(this.formatStoredItem(storedItem));
}
async createNewItem(
user: User,
storage: Storage,
body: StorageAddItemRequestDto,
) {
// Create item
let item = new Item();
item.addedBy = user;
Object.assign(
item,
pick(body, [
'displayName',
'type',
'barcode',
'consumable',
'image',
'weight',
'url',
'notes',
'public',
]),
);
item = await this.storageService.saveItem(item);
return this.createStoredItem(
user,
item,
storage,
body.transactionInfo,
body.additionalInfo,
);
}
async addExistingItemToStorage(
user: User,
storage: Storage,
itemId: number,
body: StorageAddExistingItemRequestDto,
) {
const item = await this.storageService.getItemByIdBySub(itemId, user.sub);
if (!item) {
throw new NotFoundException('Item not found');
}
return this.createStoredItem(
user,
item,
storage,
body.transactionInfo,
body.additionalInfo,
);
}
async updateOwnedItem(
user: User,
itemId: number,
body: StorageItemUpdateRequestDto,
) {
const item = await this.storageService.getItemByIdOwnedBySub(
itemId,
user.sub,
);
if (!item) {
throw new NotFoundException('Item not found');
}
Object.assign(item, body);
await this.storageService.saveItem(item);
return this.formatItem(item);
}
async updateStoredItemDetails(
storage: Storage,
storedItemId: number,
body: StorageStoredItemUpdateRequestDto,
) {
const storedItem = await this.storageService.getStoredItemByStorageAndId(
storage,
storedItemId,
);
if (!storedItem) {
throw new NotFoundException('Stored item not found');
}
Object.assign(storedItem, body);
await this.storageService.saveStoredItem(storedItem);
return this.formatStoredItem(storedItem);
}
async createStoredItemTransaction(
user: User,
storage: Storage,
storedItemId: number,
body: StorageStoredItemTransactionRequestDto,
) {
const storedItem = await this.storageService.getStoredItemByStorageAndId(
storage,
storedItemId,
);
if (!storedItem) {
throw new NotFoundException('Stored item not found');
}
const transaction = await this.storageService.saveStoredItemTransaction({
...body,
actor: user,
storedItem,
});
if (
[
TransactionType.SOLD,
TransactionType.DESTROYED,
TransactionType.BINNED,
].includes(body.type)
) {
storedItem.consumedAt = new Date();
await this.storageService.saveStoredItem(storedItem);
}
return this.formatTransactionNoDetails(transaction);
}
async getStoredItemDetails(storage: Storage, storedItemId: number) {
const storedItem = await this.storageService.getStoredItemByStorageAndId(
storage,
storedItemId,
['transactions', 'item', 'addedBy'],
);
if (!storedItem) {
throw new NotFoundException('Stored item not found');
}
return this.formatStoredItem(storedItem);
}
async getStorageWithItems(storage: Storage) {
storage = await this.storageService.getStorageById(storage.id, [
'items',
'items.addedBy',
'items.item',
]);
return this.formatStorageWithItems(storage);
}
formatActor(input: User): StorageActorResponse {
return pick(input, ['name', 'sub', 'color']);
}
@ -127,6 +326,42 @@ export class AppStorageService {
};
}
formatStorageWithItems(storage: Storage): StorageResponseDto {
return {
...omit(storage, ['room', 'set']),
items: !!storage.items?.length
? storage.items.map((item) => this.formatStoredItem(item))
: null,
addedBy: storage.addedBy && this.formatActor(storage.addedBy),
};
}
formatItem(item: Item): StorageItemResponseDto {
return {
...omit(item, ['instances', 'addedBy']),
addedBy: item.addedBy && this.formatActor(item.addedBy),
};
}
formatTransactionNoDetails(
transaction: StoredItemTransaction,
): StorageTransactionResponseDto {
return omit(transaction, ['storedItem', 'actor']);
}
formatStoredItem(storedItem: StoredItem): StorageStoredItemResponseDto {
return {
...omit(storedItem, ['storage']),
transactions: !!storedItem.transactions?.length
? storedItem.transactions.map((transaction) =>
this.formatTransactionNoDetails(transaction),
)
: null,
item: storedItem.item ? this.formatItem(storedItem.item) : null,
addedBy: storedItem.addedBy ? this.formatActor(storedItem.addedBy) : null,
};
}
formatStorageSetNoItems(set: StorageSet): StorageSetResponseDto {
return {
...omit(set, ['room']),

View File

@ -1,8 +1,8 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsDate,
IsDateString,
IsEnum,
IsNumber,
IsObject,
@ -62,6 +62,10 @@ export class StorageItemRequestDto {
public?: boolean;
}
export class StorageItemUpdateRequestDto extends PartialType(
StorageItemRequestDto,
) {}
export class StorageStoredItemRequestDto {
@ApiProperty()
@IsString()
@ -69,21 +73,25 @@ export class StorageStoredItemRequestDto {
notes?: string;
@ApiPropertyOptional()
@IsDate()
@IsDateString()
@IsOptional()
expiresAt?: Date;
@ApiPropertyOptional()
@IsDate()
@IsDateString()
@IsOptional()
acquiredAt?: Date;
@ApiPropertyOptional()
@IsDate()
@IsDateString()
@IsOptional()
consumedAt?: Date;
}
export class StorageStoredItemUpdateRequestDto extends PartialType(
StorageStoredItemRequestDto,
) {}
export class StorageStoredItemTransactionRequestDto {
@ApiProperty({ type: String, enum: TransactionType })
@IsEnum(TransactionType)
@ -107,7 +115,7 @@ export class StorageStoredItemTransactionRequestDto {
notes?: string;
@ApiPropertyOptional()
@IsDate()
@IsDateString()
@IsOptional()
actionAt?: Date;
}

View File

@ -22,14 +22,32 @@ export class StorageItemSearchResponseDto extends PickType(Item, [
'createdAt',
]) {}
export class StorageTransactionResponseDto extends OmitType(
StoredItemTransaction,
['storedItem', 'actor'],
) {}
export class StorageStoredItemResponseDto extends OmitType(StoredItem, [
'addedBy',
'transactions',
'storage',
'item',
]) {
@ApiProperty({ type: StorageActorResponse })
addedBy: StorageActorResponse;
@ApiProperty({ type: StorageItemResponseDto })
@Type(() => StorageItemResponseDto)
item: StorageItemResponseDto;
@ApiProperty({ type: StorageTransactionResponseDto, isArray: true })
@Type(() => StorageTransactionResponseDto)
transactions: StorageTransactionResponseDto[];
constructor(obj: Partial<StorageStoredItemResponseDto>) {
super(obj);
Object.assign(this, obj);
}
}
export class StorageStoredItemTransactionDto extends OmitType(

View File

@ -1,6 +1,7 @@
import { ApiPropertyOptional, OmitType, PickType } from '@nestjs/swagger';
import { Storage } from 'src/objects/storage/entities/storage.entity';
import { User } from 'src/objects/user/user.entity';
import { StorageStoredItemResponseDto } from './storage-item-response.dto';
export class StorageActorResponse extends PickType(User, [
'sub',
@ -16,4 +17,10 @@ export class StorageResponseDto extends OmitType(Storage, [
]) {
@ApiPropertyOptional({ type: StorageActorResponse })
addedBy: StorageActorResponse;
@ApiPropertyOptional({
type: () => StorageStoredItemResponseDto,
isArray: true,
})
items?: StorageStoredItemResponseDto[];
}

View File

@ -1,5 +1,4 @@
import {
Body,
ClassSerializerInterceptor,
Controller,
Get,
@ -8,19 +7,19 @@ import {
UseInterceptors,
} from '@nestjs/common';
import {
ApiBadRequestResponse,
ApiBasicAuth,
ApiBearerAuth,
ApiBody,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { User } from 'src/objects/user/user.entity';
import { LoggedInUser } from 'src/shared/decorators/user.decorator';
import { AuthGuard } from 'src/shared/guards/auth.guard';
import { LoginGuard } from 'src/shared/guards/login.guard';
import { AppUserService } from './app-user.service';
import { UserLoginResponseDto } from './dto/user-login-response.dto';
import { UserLoginDto } from './dto/user-login.dto';
@ApiTags('users')
@UseInterceptors(ClassSerializerInterceptor)
@ -31,12 +30,13 @@ export class AppUserController {
constructor(private readonly service: AppUserService) {}
@Post('login')
@ApiBody({ type: UserLoginDto })
@UseGuards(LoginGuard)
@ApiBasicAuth('Email and Password')
@ApiOperation({ summary: 'Log in using email and password' })
@ApiBadRequestResponse()
@ApiUnauthorizedResponse({ description: 'Invalid email or password' })
@ApiOkResponse({ type: UserLoginResponseDto })
async userDoLogin(@Body() body: UserLoginDto) {
return this.service.login(body);
async userDoLogin(@LoggedInUser() loginRequest: User) {
return this.service.login(loginRequest);
}
@Get()

View File

@ -1,9 +1,9 @@
import { UserService } from 'src/objects/user/user.service';
import { BadRequestException, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { UserLoginDto } from './dto/user-login.dto';
import { UserLoginResponseDto } from './dto/user-login-response.dto';
import { AuthService } from 'src/shared/auth/auth.service';
import { User } from 'src/objects/user/user.entity';
@Injectable()
export class AppUserService {
@ -13,21 +13,13 @@ export class AppUserService {
private readonly auth: AuthService,
) {}
async login({ email, password }: UserLoginDto) {
const user = await this.userService.getUserByEmail(email);
if (!user) {
throw new BadRequestException('Invalid username or password');
}
if (!(await this.auth.comparePassword(user, password))) {
throw new BadRequestException('Invalid username or password');
}
async login(user: User) {
const token = await this.auth.issueJWT(user);
return new UserLoginResponseDto({
accessToken: token,
expiresIn: this.auth.expiry,
access_token: token,
expires_in: this.auth.expiry,
token_type: 'Bearer',
});
}
}

View File

@ -1,10 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ConstructableDto } from 'src/shared/dto/constructable.dto';
export class UserLoginResponseDto extends ConstructableDto<UserLoginResponseDto> {
@ApiProperty()
accessToken: string;
access_token: string;
@ApiPropertyOptional()
refresh_token: string;
@ApiProperty()
expiresIn: number;
expires_in: number;
@ApiProperty()
token_type: string;
}

View File

@ -1,12 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString } from 'class-validator';
export class UserLoginDto {
@ApiProperty()
@IsEmail()
email: string;
@ApiProperty()
@IsString()
password: string;
}

View File

@ -19,6 +19,11 @@ async function bootstrap() {
name: 'Bearer token',
type: 'apiKey',
})
.addBasicAuth({
name: 'Email and Password',
description: 'For acquiring a Bearer token',
type: 'http',
})
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);

View File

@ -1,9 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ILike, Repository } from 'typeorm';
import { BuildingService } from '../building/building.service';
import { GroupService } from '../group/group.service';
import { UserService } from '../user/user.service';
import { StoredItemTransaction } from './entities/item-transaction.entity';
import { Item } from './entities/item.entity';
import { StorageSet } from './entities/storage-set.entity';
@ -23,11 +20,17 @@ export class StorageService {
private readonly storedItemRepository: Repository<StoredItem>,
@InjectRepository(StoredItemTransaction)
private readonly transactionRepository: Repository<StoredItemTransaction>,
private readonly groupService: GroupService,
private readonly userService: UserService,
private readonly buildingService: BuildingService,
) {}
async getStorageById(id: number, relations = []) {
return this.storageRepository.findOne({
where: {
id,
},
relations,
});
}
async getStorageByIdAndSub(id: number, sub: string, relations = []) {
return this.storageRepository.findOne({
where: {
@ -216,6 +219,59 @@ export class StorageService {
});
}
async getItemByIdBySub(id: number, sub: string) {
return this.itemRepository.findOne({
where: [
{
id,
addedBy: {
sub,
},
},
{
id,
public: true,
},
{
id,
addedBy: {
groups: {
members: {
sub,
},
},
},
},
],
});
}
async getItemByIdOwnedBySub(id: number, sub: string, relations = []) {
return this.itemRepository.findOne({
where: {
id,
addedBy: {
sub,
},
},
relations,
});
}
async getStoredItemByStorageAndId(
storage: Storage,
storedItemId: number,
relations = [],
) {
return this.storedItemRepository.findOne({
where: {
id: storedItemId,
storage: { id: storage.id },
},
relations,
});
}
async saveStorage(data: Partial<Storage>) {
const newStorage = new Storage();
Object.assign(newStorage, data);

View File

@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { Exclude } from 'class-transformer';
import {
Column,
CreateDateColumn,
@ -34,6 +35,7 @@ export class User {
emailVerified: boolean;
@Column()
@Exclude()
password: string;
@ApiProperty()

View File

@ -0,0 +1,45 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { UserService } from 'src/objects/user/user.service';
import { AuthService } from '../auth/auth.service';
@Injectable()
export class LoginGuard implements CanActivate {
constructor(
private readonly authService: AuthService,
private readonly userService: UserService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const http = context.switchToHttp();
const request = http.getRequest();
const response = http.getResponse();
const authHeader = request.header('authorization');
if (!authHeader) return false;
const [method, token] = authHeader.split(' ');
if (!token || method !== 'Basic') return false;
const [email, password] = Buffer.from(token, 'base64')
.toString()
.split(':');
const user = await this.userService.getUserByEmail(email);
if (!user) {
throw new UnauthorizedException('Invalid username or password');
}
if (!(await this.authService.comparePassword(user, password))) {
throw new UnauthorizedException('Invalid username or password');
}
response.locals.user = user;
return true;
}
}