feat: Add functionality to move inventory items to pantry and enhance pantry management
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
- Implemented moveInventoryItemToPantry method in InventoryRepository to facilitate moving items from inventory to pantry. - Enhanced InventoryScreen with a new header section providing context about the inventory. - Added a button in SwipeableInventoryTile to move items to pantry with appropriate error handling. - Introduced movePantryItemToInventory method in PantryRepository to support moving items back to inventory. - Refactored PantryScreen to rename _addToInventory to _moveToInventory for clarity and updated UI to reflect changes. - Added AdminPantryItem model to represent pantry items in the admin panel. - Created AdminPantryPanel for managing pantry items, including moving items to inventory and listing users. - Developed AdminPrivateProductsPanel for managing private products, allowing promotion to global products.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
@@ -25,15 +26,33 @@ export class InventoryController {
|
||||
@Roles('admin')
|
||||
@Get('admin')
|
||||
findAllAdmin(
|
||||
@Query('userId', new ParseIntPipe({ optional: true })) userId?: number,
|
||||
@Query('userId') userIdRaw?: string,
|
||||
@Query('sort') sort?: string,
|
||||
) {
|
||||
const userId = this.parseOptionalIntQuery(userIdRaw);
|
||||
return this.inventoryService.findAllAdmin({
|
||||
userId,
|
||||
sort,
|
||||
});
|
||||
}
|
||||
|
||||
private parseOptionalIntQuery(value: string | undefined): number | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
throw new BadRequestException('Validation failed (numeric string is expected)');
|
||||
}
|
||||
|
||||
return Number(trimmed);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Post('admin')
|
||||
createAdmin(
|
||||
@@ -134,4 +153,18 @@ findConsumptionHistory(
|
||||
) {
|
||||
return this.inventoryService.remove(id, user.userId);
|
||||
}
|
||||
|
||||
@Post(':id/move-to-pantry')
|
||||
moveToPantry(
|
||||
@CurrentUser() user: { userId: number },
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
) {
|
||||
return this.inventoryService.moveToPantry(id, user.userId);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Post('admin/:id/move-to-pantry')
|
||||
moveToPantryAdmin(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.inventoryService.moveToPantryAdmin(id);
|
||||
}
|
||||
}
|
||||
@@ -348,6 +348,51 @@ export class InventoryService {
|
||||
return this.prisma.inventoryItem.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private async moveInventoryItemToPantryCore(item: {
|
||||
id: number;
|
||||
userId: number;
|
||||
productId: number;
|
||||
location: string | null;
|
||||
}) {
|
||||
const existingPantryItem = await this.prisma.pantryItem.findUnique({
|
||||
where: {
|
||||
userId_productId: {
|
||||
userId: item.userId,
|
||||
productId: item.productId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingPantryItem) {
|
||||
throw new BadRequestException('Produkten finns redan i baslagret');
|
||||
}
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const pantryItem = await tx.pantryItem.create({
|
||||
data: {
|
||||
userId: item.userId,
|
||||
productId: item.productId,
|
||||
location: item.location?.trim() || null,
|
||||
},
|
||||
include: { product: true },
|
||||
});
|
||||
|
||||
await tx.inventoryItem.delete({ where: { id: item.id } });
|
||||
|
||||
return pantryItem;
|
||||
});
|
||||
}
|
||||
|
||||
async moveToPantry(id: number, userId: number) {
|
||||
const item = await this.findInventoryItemByIdOrThrow(id, userId);
|
||||
return this.moveInventoryItemToPantryCore(item);
|
||||
}
|
||||
|
||||
async moveToPantryAdmin(id: number) {
|
||||
const item = await this.findInventoryItemAnyByIdOrThrow(id);
|
||||
return this.moveInventoryItemToPantryCore(item);
|
||||
}
|
||||
|
||||
async updateAdmin(id: number, data: UpdateInventoryDto) {
|
||||
await this.findInventoryItemAnyByIdOrThrow(id);
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
|
||||
import { PantryService } from './pantry.service';
|
||||
import { CreatePantryItemDto } from './dto/create-pantry-item.dto';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { CreateInventoryDto } from '../inventory/dto/create-inventory.dto';
|
||||
|
||||
@Controller('pantry')
|
||||
export class PantryController {
|
||||
@@ -20,6 +22,15 @@ export class PantryController {
|
||||
return this.pantryService.create(user.userId, body);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Get('admin')
|
||||
findAllAdmin(@Query('userId') userIdRaw?: string) {
|
||||
const userId = userIdRaw == null || userIdRaw.trim() === '' ? undefined : Number(userIdRaw);
|
||||
return this.pantryService.findAllAdmin({
|
||||
userId: Number.isFinite(userId as number) ? (userId as number) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@CurrentUser() user: { userId: number },
|
||||
@@ -27,4 +38,28 @@ export class PantryController {
|
||||
) {
|
||||
return this.pantryService.remove(user.userId, id);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Delete('admin/:id')
|
||||
removeAdmin(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.pantryService.removeAdmin(id);
|
||||
}
|
||||
|
||||
@Post(':id/move-to-inventory')
|
||||
moveToInventory(
|
||||
@CurrentUser() user: { userId: number },
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: CreateInventoryDto,
|
||||
) {
|
||||
return this.pantryService.moveToInventory(user.userId, id, body);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Post('admin/:id/move-to-inventory')
|
||||
moveToInventoryAdmin(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: CreateInventoryDto,
|
||||
) {
|
||||
return this.pantryService.moveToInventoryAdmin(id, body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Injectable, ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreatePantryItemDto } from './dto/create-pantry-item.dto';
|
||||
import { CreateInventoryDto } from '../inventory/dto/create-inventory.dto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
type PantryQuery = {
|
||||
userId?: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PantryService {
|
||||
@@ -18,6 +24,38 @@ export class PantryService {
|
||||
});
|
||||
}
|
||||
|
||||
findAllAdmin(query?: PantryQuery) {
|
||||
return this.prisma.pantryItem.findMany({
|
||||
where: typeof query?.userId === 'number' ? { userId: query.userId } : {},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
product: {
|
||||
include: {
|
||||
categoryRef: {
|
||||
include: {
|
||||
parent: {
|
||||
include: {
|
||||
parent: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ user: { username: 'asc' } },
|
||||
{ product: { name: 'asc' } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async create(userId: number, data: CreatePantryItemDto) {
|
||||
const existing = await this.prisma.pantryItem.findUnique({
|
||||
where: {
|
||||
@@ -53,4 +91,68 @@ export class PantryService {
|
||||
|
||||
return this.prisma.pantryItem.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async removeAdmin(id: number) {
|
||||
const item = await this.prisma.pantryItem.findUnique({ where: { id } });
|
||||
|
||||
if (!item) {
|
||||
throw new NotFoundException(`PantryItem med id ${id} hittades inte`);
|
||||
}
|
||||
|
||||
return this.prisma.pantryItem.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private async movePantryItemToInventoryCore(
|
||||
item: { id: number; userId: number; productId: number; location: string | null },
|
||||
data: CreateInventoryDto,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const inventoryItem = await tx.inventoryItem.create({
|
||||
data: {
|
||||
userId: item.userId,
|
||||
productId: item.productId,
|
||||
quantity: new Prisma.Decimal(data.quantity),
|
||||
unit: data.unit.trim(),
|
||||
location: data.location?.trim() || item.location || undefined,
|
||||
purchaseDate: data.purchaseDate ? new Date(data.purchaseDate) : undefined,
|
||||
bestBeforeDate: data.bestBeforeDate ? new Date(data.bestBeforeDate) : undefined,
|
||||
brand: data.brand?.trim() || undefined,
|
||||
origin: data.origin?.trim() || undefined,
|
||||
receiptName: data.receiptName?.trim() || undefined,
|
||||
opened: data.opened ?? false,
|
||||
suitableFor: data.suitableFor?.trim() || undefined,
|
||||
comment: data.comment?.trim() || undefined,
|
||||
},
|
||||
include: { product: true },
|
||||
});
|
||||
|
||||
await tx.pantryItem.delete({ where: { id: item.id } });
|
||||
|
||||
return inventoryItem;
|
||||
});
|
||||
}
|
||||
|
||||
async moveToInventory(userId: number, pantryItemId: number, data: CreateInventoryDto) {
|
||||
const item = await this.prisma.pantryItem.findFirst({
|
||||
where: { id: pantryItemId, userId },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
throw new NotFoundException(`PantryItem med id ${pantryItemId} hittades inte`);
|
||||
}
|
||||
|
||||
return this.movePantryItemToInventoryCore(item, data);
|
||||
}
|
||||
|
||||
async moveToInventoryAdmin(pantryItemId: number, data: CreateInventoryDto) {
|
||||
const item = await this.prisma.pantryItem.findUnique({
|
||||
where: { id: pantryItemId },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
throw new NotFoundException(`PantryItem med id ${pantryItemId} hittades inte`);
|
||||
}
|
||||
|
||||
return this.movePantryItemToInventoryCore(item, data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,12 @@ export class ProductsController {
|
||||
return this.productsService.findPending();
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Get('private')
|
||||
findPrivate() {
|
||||
return this.productsService.findPrivate();
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Post('ai-categorize-bulk')
|
||||
@Throttle({ default: { ttl: 60_000, limit: 5 } })
|
||||
@@ -202,6 +208,15 @@ export class ProductsController {
|
||||
return this.productsService.update(id, body);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Post('private/:id/promote')
|
||||
promotePrivateToGlobal(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Request() req: { user: { id: number } },
|
||||
) {
|
||||
return this.productsService.promotePrivateToGlobal(id, req.user.id);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Delete(':id/permanent')
|
||||
permanentDelete(@Param('id', ParseIntPipe) id: number) {
|
||||
|
||||
@@ -435,6 +435,17 @@ export class ProductsService {
|
||||
});
|
||||
}
|
||||
|
||||
async findPrivate() {
|
||||
return this.prisma.product.findMany({
|
||||
where: { isPrivate: true, isActive: true },
|
||||
include: {
|
||||
categoryRef: { include: { parent: true } },
|
||||
owner: { select: { id: true, username: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async createPending(data: CreateProductDto, userId: number) {
|
||||
const name = data.name.trim();
|
||||
const normalizedName = normalizeName(name);
|
||||
@@ -470,6 +481,67 @@ export class ProductsService {
|
||||
return this.prisma.product.update({ where: { id }, data: { status } });
|
||||
}
|
||||
|
||||
async promotePrivateToGlobal(productId: number, adminUserId: number) {
|
||||
const source = await this.prisma.product.findUnique({
|
||||
where: { id: productId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
canonicalName: true,
|
||||
normalizedName: true,
|
||||
categoryId: true,
|
||||
isPrivate: true,
|
||||
isActive: true,
|
||||
ownerId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!source) {
|
||||
throw new NotFoundException(`Product with id ${productId} not found`);
|
||||
}
|
||||
|
||||
if (!source.isPrivate) {
|
||||
throw new ForbiddenException('Endast privata produkter kan promoveras till global produkt');
|
||||
}
|
||||
|
||||
const name = (source.canonicalName ?? source.name).trim();
|
||||
const normalizedName = normalizeName(name);
|
||||
|
||||
const existingGlobal = await this.prisma.product.findUnique({
|
||||
where: { normalizedName },
|
||||
});
|
||||
|
||||
if (existingGlobal && existingGlobal.id !== source.id) {
|
||||
if (!existingGlobal.isActive) {
|
||||
return this.prisma.product.update({
|
||||
where: { id: existingGlobal.id },
|
||||
data: {
|
||||
isActive: true,
|
||||
deletedAt: null,
|
||||
name,
|
||||
canonicalName: name,
|
||||
categoryId: source.categoryId ?? existingGlobal.categoryId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return existingGlobal;
|
||||
}
|
||||
|
||||
return this.prisma.product.create({
|
||||
data: {
|
||||
name,
|
||||
normalizedName,
|
||||
canonicalName: name,
|
||||
isActive: true,
|
||||
isPrivate: false,
|
||||
ownerId: adminUserId,
|
||||
deletedAt: null,
|
||||
...(source.categoryId != null ? { categoryId: source.categoryId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Privata produkter (användare kan hantera sina egna) ──────────────────────
|
||||
// Hjälpfunktioner för att undvika kodduplicering mellan admin och user-scope
|
||||
|
||||
|
||||
Reference in New Issue
Block a user