feat: implement recipe analysis service and data models
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
- Added RecipeAnalysisService to handle recipe ingredient analysis, including methods for checking ingredient availability and calculating quantities. - Introduced new TypeScript definitions for recipe analysis results, including ingredient status and summary. - Created corresponding Dart models for recipe analysis, including RecipeIngredientAnalysis, RecipeAnalysisSummary, and RecipeShoppingCandidate. - Updated Flutter UI to reflect changes in ingredient availability status. - Fixed color opacity issue in recipe image card.
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
type AnalysisStatus = 'exact_match' | 'covered_by_pantry' | 'substitutable' | 'missing';
|
||||
export declare class RecipeAnalysisService {
|
||||
private readonly prisma;
|
||||
constructor(prisma: PrismaService);
|
||||
private getAccessibleRecipe;
|
||||
private calculateAvailableQuantity;
|
||||
analyzeRecipeIngredients(id: number, userId: number): Promise<{
|
||||
recipeId: number;
|
||||
ingredients: ({
|
||||
ingredientId: any;
|
||||
rawName: any;
|
||||
quantity: number;
|
||||
unit: any;
|
||||
note: any;
|
||||
status: AnalysisStatus;
|
||||
matchedProductId: any;
|
||||
matchedProductName: any;
|
||||
source: string;
|
||||
availableQuantity: number;
|
||||
missingQuantity: number;
|
||||
} | {
|
||||
ingredientId: any;
|
||||
rawName: any;
|
||||
quantity: number;
|
||||
unit: any;
|
||||
note: any;
|
||||
status: AnalysisStatus;
|
||||
matchedProductId: any;
|
||||
matchedProductName: any;
|
||||
source: null;
|
||||
availableQuantity: number;
|
||||
missingQuantity: number;
|
||||
})[];
|
||||
summary: {
|
||||
exactCount: number;
|
||||
pantryCount: number;
|
||||
substituteCount: number;
|
||||
missingCount: number;
|
||||
};
|
||||
shoppingListCandidates: {
|
||||
ingredientId: any;
|
||||
rawName: any;
|
||||
quantity: number;
|
||||
unit: any;
|
||||
missingQuantity: number;
|
||||
}[];
|
||||
}>;
|
||||
}
|
||||
export {};
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.RecipeAnalysisService = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const prisma_service_1 = require("../prisma/prisma.service");
|
||||
const units_1 = require("../common/utils/units");
|
||||
let RecipeAnalysisService = class RecipeAnalysisService {
|
||||
constructor(prisma) {
|
||||
this.prisma = prisma;
|
||||
}
|
||||
async getAccessibleRecipe(id, userId) {
|
||||
const recipe = await this.prisma.recipe.findFirst({
|
||||
where: {
|
||||
id,
|
||||
OR: [
|
||||
{ isPublic: true },
|
||||
{ ownerId: userId },
|
||||
{ shares: { some: { userId } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
ingredients: {
|
||||
include: {
|
||||
product: true,
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!recipe) {
|
||||
throw new common_1.NotFoundException(`Recipe with id ${id} not found`);
|
||||
}
|
||||
return recipe;
|
||||
}
|
||||
calculateAvailableQuantity(inventoryItems, requiredUnit) {
|
||||
if (!requiredUnit) {
|
||||
return inventoryItems.reduce((sum, item) => sum + Number(item.quantity ?? 0), 0);
|
||||
}
|
||||
const normalizedRequiredUnit = requiredUnit.trim().toLowerCase();
|
||||
const sameUnit = inventoryItems
|
||||
.filter((item) => item.unit.trim().toLowerCase() === normalizedRequiredUnit)
|
||||
.reduce((sum, item) => sum + Number(item.quantity ?? 0), 0);
|
||||
const converted = inventoryItems
|
||||
.filter((item) => item.unit.trim().toLowerCase() !== normalizedRequiredUnit)
|
||||
.reduce((sum, item) => {
|
||||
if (!(0, units_1.canConvert)(item.unit, requiredUnit))
|
||||
return sum;
|
||||
try {
|
||||
return sum + (0, units_1.convertUnit)(Number(item.quantity ?? 0), item.unit, requiredUnit);
|
||||
}
|
||||
catch {
|
||||
return sum;
|
||||
}
|
||||
}, 0);
|
||||
return sameUnit + converted;
|
||||
}
|
||||
async analyzeRecipeIngredients(id, userId) {
|
||||
const recipe = await this.getAccessibleRecipe(id, userId);
|
||||
const pantryItems = await this.prisma.pantryItem.findMany({
|
||||
where: { userId },
|
||||
select: { productId: true },
|
||||
});
|
||||
const pantryProductIds = new Set(pantryItems.map((p) => p.productId));
|
||||
const ingredients = await Promise.all(recipe.ingredients.map(async (ingredient) => {
|
||||
const requiredQuantity = Number(ingredient.quantity ?? 0);
|
||||
const requiredUnit = (ingredient.unit ?? '').trim();
|
||||
const rawName = (ingredient.rawName ?? '').trim() || 'Okänd ingrediens';
|
||||
if (!ingredient.productId || !ingredient.product) {
|
||||
return {
|
||||
ingredientId: ingredient.id,
|
||||
rawName,
|
||||
quantity: requiredQuantity,
|
||||
unit: requiredUnit,
|
||||
note: ingredient.note ?? null,
|
||||
status: 'missing',
|
||||
matchedProductId: null,
|
||||
matchedProductName: null,
|
||||
source: null,
|
||||
availableQuantity: 0,
|
||||
missingQuantity: requiredQuantity,
|
||||
};
|
||||
}
|
||||
if (pantryProductIds.has(ingredient.productId)) {
|
||||
return {
|
||||
ingredientId: ingredient.id,
|
||||
rawName,
|
||||
quantity: requiredQuantity,
|
||||
unit: requiredUnit,
|
||||
note: ingredient.note ?? null,
|
||||
status: 'covered_by_pantry',
|
||||
matchedProductId: ingredient.productId,
|
||||
matchedProductName: ingredient.product.canonicalName || ingredient.product.name,
|
||||
source: 'pantry',
|
||||
availableQuantity: requiredQuantity,
|
||||
missingQuantity: 0,
|
||||
};
|
||||
}
|
||||
const inventoryItems = await this.prisma.inventoryItem.findMany({
|
||||
where: { productId: ingredient.productId },
|
||||
select: { quantity: true, unit: true },
|
||||
});
|
||||
const availableQuantity = this.calculateAvailableQuantity(inventoryItems, requiredUnit);
|
||||
if (availableQuantity >= requiredQuantity) {
|
||||
return {
|
||||
ingredientId: ingredient.id,
|
||||
rawName,
|
||||
quantity: requiredQuantity,
|
||||
unit: requiredUnit,
|
||||
note: ingredient.note ?? null,
|
||||
status: 'exact_match',
|
||||
matchedProductId: ingredient.productId,
|
||||
matchedProductName: ingredient.product.canonicalName || ingredient.product.name,
|
||||
source: 'inventory',
|
||||
availableQuantity,
|
||||
missingQuantity: 0,
|
||||
};
|
||||
}
|
||||
const alternativeProductIds = Array.isArray(ingredient.alternativeProductIds)
|
||||
? ingredient.alternativeProductIds.filter((id) => typeof id === 'number')
|
||||
: [];
|
||||
for (const altProductId of alternativeProductIds) {
|
||||
if (pantryProductIds.has(altProductId)) {
|
||||
const altProduct = await this.prisma.product.findUnique({
|
||||
where: { id: altProductId },
|
||||
select: { id: true, name: true, canonicalName: true },
|
||||
});
|
||||
return {
|
||||
ingredientId: ingredient.id,
|
||||
rawName,
|
||||
quantity: requiredQuantity,
|
||||
unit: requiredUnit,
|
||||
note: ingredient.note ?? null,
|
||||
status: 'substitutable',
|
||||
matchedProductId: altProduct?.id ?? altProductId,
|
||||
matchedProductName: altProduct?.canonicalName || altProduct?.name || null,
|
||||
source: 'pantry_substitute',
|
||||
availableQuantity: requiredQuantity,
|
||||
missingQuantity: 0,
|
||||
};
|
||||
}
|
||||
const altInventoryItems = await this.prisma.inventoryItem.findMany({
|
||||
where: { productId: altProductId },
|
||||
select: { quantity: true, unit: true },
|
||||
});
|
||||
const altAvailable = this.calculateAvailableQuantity(altInventoryItems, requiredUnit);
|
||||
if (altAvailable > 0) {
|
||||
const altProduct = await this.prisma.product.findUnique({
|
||||
where: { id: altProductId },
|
||||
select: { id: true, name: true, canonicalName: true },
|
||||
});
|
||||
return {
|
||||
ingredientId: ingredient.id,
|
||||
rawName,
|
||||
quantity: requiredQuantity,
|
||||
unit: requiredUnit,
|
||||
note: ingredient.note ?? null,
|
||||
status: 'substitutable',
|
||||
matchedProductId: altProduct?.id ?? altProductId,
|
||||
matchedProductName: altProduct?.canonicalName || altProduct?.name || null,
|
||||
source: 'inventory_substitute',
|
||||
availableQuantity: altAvailable,
|
||||
missingQuantity: Math.max(0, requiredQuantity - altAvailable),
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
ingredientId: ingredient.id,
|
||||
rawName,
|
||||
quantity: requiredQuantity,
|
||||
unit: requiredUnit,
|
||||
note: ingredient.note ?? null,
|
||||
status: 'missing',
|
||||
matchedProductId: ingredient.productId,
|
||||
matchedProductName: ingredient.product.canonicalName || ingredient.product.name,
|
||||
source: null,
|
||||
availableQuantity,
|
||||
missingQuantity: Math.max(0, requiredQuantity - availableQuantity),
|
||||
};
|
||||
}));
|
||||
const summary = {
|
||||
exactCount: ingredients.filter((i) => i.status === 'exact_match').length,
|
||||
pantryCount: ingredients.filter((i) => i.status === 'covered_by_pantry').length,
|
||||
substituteCount: ingredients.filter((i) => i.status === 'substitutable').length,
|
||||
missingCount: ingredients.filter((i) => i.status === 'missing').length,
|
||||
};
|
||||
const shoppingListCandidates = ingredients
|
||||
.filter((i) => i.status === 'missing')
|
||||
.map((i) => ({
|
||||
ingredientId: i.ingredientId,
|
||||
rawName: i.rawName,
|
||||
quantity: i.quantity,
|
||||
unit: i.unit,
|
||||
missingQuantity: i.missingQuantity,
|
||||
}));
|
||||
return {
|
||||
recipeId: recipe.id,
|
||||
ingredients,
|
||||
summary,
|
||||
shoppingListCandidates,
|
||||
};
|
||||
}
|
||||
};
|
||||
exports.RecipeAnalysisService = RecipeAnalysisService;
|
||||
exports.RecipeAnalysisService = RecipeAnalysisService = __decorate([
|
||||
(0, common_1.Injectable)(),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
|
||||
], RecipeAnalysisService);
|
||||
//# sourceMappingURL=recipe-analysis.service.js.map
|
||||
File diff suppressed because one or more lines are too long
+221
-176
@@ -4,12 +4,14 @@ import { CreateIngredientDto } from './dto/create-ingredient.dto';
|
||||
import { ParseMarkdownDto } from './dto/parse-markdown.dto';
|
||||
import { ShareRecipeDto } from './dto/share-recipe.dto';
|
||||
import { SetRecipeVisibilityDto } from './dto/set-recipe-visibility.dto';
|
||||
import { RecipeAnalysisService } from './recipe-analysis.service';
|
||||
declare class UpdateImageDto {
|
||||
sourceUrl: string;
|
||||
}
|
||||
export declare class RecipesController {
|
||||
private readonly recipesService;
|
||||
constructor(recipesService: RecipesService);
|
||||
private readonly recipeAnalysisService;
|
||||
constructor(recipesService: RecipesService, recipeAnalysisService: RecipeAnalysisService);
|
||||
parseMarkdown(dto: ParseMarkdownDto): Promise<{
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -43,6 +45,8 @@ export declare class RecipesController {
|
||||
ingredients: ({
|
||||
product: ({
|
||||
nutrition: {
|
||||
id: number;
|
||||
productId: number;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
@@ -50,54 +54,52 @@ export declare class RecipesController {
|
||||
salt: number | null;
|
||||
sugar: number | null;
|
||||
fiber: number | null;
|
||||
id: number;
|
||||
productId: number;
|
||||
} | null;
|
||||
} & {
|
||||
category: string | null;
|
||||
status: string;
|
||||
name: string;
|
||||
categoryId: number | null;
|
||||
canonicalName: string | null;
|
||||
id: number;
|
||||
normalizedName: string;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
name: string;
|
||||
ownerId: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number;
|
||||
status: string;
|
||||
normalizedName: string;
|
||||
category: string | null;
|
||||
canonicalName: string | null;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
categoryId: number | null;
|
||||
isPrivate: boolean;
|
||||
}) | null;
|
||||
} & {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
recipeId: number;
|
||||
productId: number | null;
|
||||
quantity: import("@prisma/client/runtime/library").Decimal | null;
|
||||
unit: string | null;
|
||||
rawName: string;
|
||||
rawLine: string | null;
|
||||
quantity: import("@prisma/client/runtime/library").Decimal | null;
|
||||
unit: string | null;
|
||||
note: string | null;
|
||||
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
|
||||
matchConfidence: number | null;
|
||||
matchSource: string | null;
|
||||
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
|
||||
recipeId: number;
|
||||
analysisStatus: string | null;
|
||||
})[];
|
||||
shares: {
|
||||
userId: number;
|
||||
}[];
|
||||
} & {
|
||||
isPublic: boolean;
|
||||
name: string;
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
imageUrl: string | null;
|
||||
servings: number | null;
|
||||
isPublic: boolean;
|
||||
ownerId: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
})[]>;
|
||||
getInventoryPreview(id: number, user: {
|
||||
userId: number;
|
||||
@@ -145,6 +147,49 @@ export declare class RecipesController {
|
||||
pantryCount: number;
|
||||
};
|
||||
}>;
|
||||
getRecipeAnalysis(id: number, user: {
|
||||
userId: number;
|
||||
}): Promise<{
|
||||
recipeId: number;
|
||||
ingredients: ({
|
||||
ingredientId: any;
|
||||
rawName: any;
|
||||
quantity: number;
|
||||
unit: any;
|
||||
note: any;
|
||||
status: "missing" | "exact_match" | "covered_by_pantry" | "substitutable";
|
||||
matchedProductId: any;
|
||||
matchedProductName: any;
|
||||
source: string;
|
||||
availableQuantity: number;
|
||||
missingQuantity: number;
|
||||
} | {
|
||||
ingredientId: any;
|
||||
rawName: any;
|
||||
quantity: number;
|
||||
unit: any;
|
||||
note: any;
|
||||
status: "missing" | "exact_match" | "covered_by_pantry" | "substitutable";
|
||||
matchedProductId: any;
|
||||
matchedProductName: any;
|
||||
source: null;
|
||||
availableQuantity: number;
|
||||
missingQuantity: number;
|
||||
})[];
|
||||
summary: {
|
||||
exactCount: number;
|
||||
pantryCount: number;
|
||||
substituteCount: number;
|
||||
missingCount: number;
|
||||
};
|
||||
shoppingListCandidates: {
|
||||
ingredientId: any;
|
||||
rawName: any;
|
||||
quantity: number;
|
||||
unit: any;
|
||||
missingQuantity: number;
|
||||
}[];
|
||||
}>;
|
||||
findOne(id: number, user: {
|
||||
userId: number;
|
||||
}): Promise<{
|
||||
@@ -155,6 +200,8 @@ export declare class RecipesController {
|
||||
ingredients: ({
|
||||
product: ({
|
||||
nutrition: {
|
||||
id: number;
|
||||
productId: number;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
@@ -162,54 +209,52 @@ export declare class RecipesController {
|
||||
salt: number | null;
|
||||
sugar: number | null;
|
||||
fiber: number | null;
|
||||
id: number;
|
||||
productId: number;
|
||||
} | null;
|
||||
} & {
|
||||
category: string | null;
|
||||
status: string;
|
||||
name: string;
|
||||
categoryId: number | null;
|
||||
canonicalName: string | null;
|
||||
id: number;
|
||||
normalizedName: string;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
name: string;
|
||||
ownerId: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number;
|
||||
status: string;
|
||||
normalizedName: string;
|
||||
category: string | null;
|
||||
canonicalName: string | null;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
categoryId: number | null;
|
||||
isPrivate: boolean;
|
||||
}) | null;
|
||||
} & {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
recipeId: number;
|
||||
productId: number | null;
|
||||
quantity: import("@prisma/client/runtime/library").Decimal | null;
|
||||
unit: string | null;
|
||||
rawName: string;
|
||||
rawLine: string | null;
|
||||
quantity: import("@prisma/client/runtime/library").Decimal | null;
|
||||
unit: string | null;
|
||||
note: string | null;
|
||||
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
|
||||
matchConfidence: number | null;
|
||||
matchSource: string | null;
|
||||
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
|
||||
recipeId: number;
|
||||
analysisStatus: string | null;
|
||||
})[];
|
||||
shares: {
|
||||
userId: number;
|
||||
}[];
|
||||
} & {
|
||||
isPublic: boolean;
|
||||
name: string;
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
imageUrl: string | null;
|
||||
servings: number | null;
|
||||
isPublic: boolean;
|
||||
ownerId: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
create(createRecipeDto: CreateRecipeDto, user: {
|
||||
userId: number;
|
||||
@@ -217,6 +262,8 @@ export declare class RecipesController {
|
||||
ingredients: ({
|
||||
product: ({
|
||||
nutrition: {
|
||||
id: number;
|
||||
productId: number;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
@@ -224,51 +271,49 @@ export declare class RecipesController {
|
||||
salt: number | null;
|
||||
sugar: number | null;
|
||||
fiber: number | null;
|
||||
id: number;
|
||||
productId: number;
|
||||
} | null;
|
||||
} & {
|
||||
category: string | null;
|
||||
status: string;
|
||||
name: string;
|
||||
categoryId: number | null;
|
||||
canonicalName: string | null;
|
||||
id: number;
|
||||
normalizedName: string;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
name: string;
|
||||
ownerId: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number;
|
||||
status: string;
|
||||
normalizedName: string;
|
||||
category: string | null;
|
||||
canonicalName: string | null;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
categoryId: number | null;
|
||||
isPrivate: boolean;
|
||||
}) | null;
|
||||
} & {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
recipeId: number;
|
||||
productId: number | null;
|
||||
quantity: import("@prisma/client/runtime/library").Decimal | null;
|
||||
unit: string | null;
|
||||
rawName: string;
|
||||
rawLine: string | null;
|
||||
quantity: import("@prisma/client/runtime/library").Decimal | null;
|
||||
unit: string | null;
|
||||
note: string | null;
|
||||
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
|
||||
matchConfidence: number | null;
|
||||
matchSource: string | null;
|
||||
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
|
||||
recipeId: number;
|
||||
analysisStatus: string | null;
|
||||
})[];
|
||||
} & {
|
||||
isPublic: boolean;
|
||||
name: string;
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
imageUrl: string | null;
|
||||
servings: number | null;
|
||||
isPublic: boolean;
|
||||
ownerId: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
update(id: number, createRecipeDto: CreateRecipeDto, user: {
|
||||
userId: number;
|
||||
@@ -276,6 +321,8 @@ export declare class RecipesController {
|
||||
ingredients: ({
|
||||
product: ({
|
||||
nutrition: {
|
||||
id: number;
|
||||
productId: number;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
@@ -283,51 +330,49 @@ export declare class RecipesController {
|
||||
salt: number | null;
|
||||
sugar: number | null;
|
||||
fiber: number | null;
|
||||
id: number;
|
||||
productId: number;
|
||||
} | null;
|
||||
} & {
|
||||
category: string | null;
|
||||
status: string;
|
||||
name: string;
|
||||
categoryId: number | null;
|
||||
canonicalName: string | null;
|
||||
id: number;
|
||||
normalizedName: string;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
name: string;
|
||||
ownerId: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number;
|
||||
status: string;
|
||||
normalizedName: string;
|
||||
category: string | null;
|
||||
canonicalName: string | null;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
categoryId: number | null;
|
||||
isPrivate: boolean;
|
||||
}) | null;
|
||||
} & {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
recipeId: number;
|
||||
productId: number | null;
|
||||
quantity: import("@prisma/client/runtime/library").Decimal | null;
|
||||
unit: string | null;
|
||||
rawName: string;
|
||||
rawLine: string | null;
|
||||
quantity: import("@prisma/client/runtime/library").Decimal | null;
|
||||
unit: string | null;
|
||||
note: string | null;
|
||||
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
|
||||
matchConfidence: number | null;
|
||||
matchSource: string | null;
|
||||
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
|
||||
recipeId: number;
|
||||
analysisStatus: string | null;
|
||||
})[];
|
||||
} & {
|
||||
isPublic: boolean;
|
||||
name: string;
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
imageUrl: string | null;
|
||||
servings: number | null;
|
||||
isPublic: boolean;
|
||||
ownerId: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
remove(id: number, user: {
|
||||
userId: number;
|
||||
@@ -342,6 +387,8 @@ export declare class RecipesController {
|
||||
ingredients: ({
|
||||
product: ({
|
||||
nutrition: {
|
||||
id: number;
|
||||
productId: number;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
@@ -349,60 +396,60 @@ export declare class RecipesController {
|
||||
salt: number | null;
|
||||
sugar: number | null;
|
||||
fiber: number | null;
|
||||
id: number;
|
||||
productId: number;
|
||||
} | null;
|
||||
} & {
|
||||
category: string | null;
|
||||
status: string;
|
||||
name: string;
|
||||
categoryId: number | null;
|
||||
canonicalName: string | null;
|
||||
id: number;
|
||||
normalizedName: string;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
name: string;
|
||||
ownerId: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number;
|
||||
status: string;
|
||||
normalizedName: string;
|
||||
category: string | null;
|
||||
canonicalName: string | null;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
categoryId: number | null;
|
||||
isPrivate: boolean;
|
||||
}) | null;
|
||||
} & {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
recipeId: number;
|
||||
productId: number | null;
|
||||
quantity: import("@prisma/client/runtime/library").Decimal | null;
|
||||
unit: string | null;
|
||||
rawName: string;
|
||||
rawLine: string | null;
|
||||
quantity: import("@prisma/client/runtime/library").Decimal | null;
|
||||
unit: string | null;
|
||||
note: string | null;
|
||||
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
|
||||
matchConfidence: number | null;
|
||||
matchSource: string | null;
|
||||
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
|
||||
recipeId: number;
|
||||
analysisStatus: string | null;
|
||||
})[];
|
||||
shares: {
|
||||
userId: number;
|
||||
}[];
|
||||
} & {
|
||||
isPublic: boolean;
|
||||
name: string;
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
imageUrl: string | null;
|
||||
servings: number | null;
|
||||
isPublic: boolean;
|
||||
ownerId: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
addIngredient(id: number, ingredient: CreateIngredientDto, user: {
|
||||
userId: number;
|
||||
}): Promise<{
|
||||
product: ({
|
||||
nutrition: {
|
||||
id: number;
|
||||
productId: number;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
@@ -410,38 +457,36 @@ export declare class RecipesController {
|
||||
salt: number | null;
|
||||
sugar: number | null;
|
||||
fiber: number | null;
|
||||
id: number;
|
||||
productId: number;
|
||||
} | null;
|
||||
} & {
|
||||
category: string | null;
|
||||
status: string;
|
||||
name: string;
|
||||
categoryId: number | null;
|
||||
canonicalName: string | null;
|
||||
id: number;
|
||||
normalizedName: string;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
name: string;
|
||||
ownerId: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number;
|
||||
status: string;
|
||||
normalizedName: string;
|
||||
category: string | null;
|
||||
canonicalName: string | null;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
categoryId: number | null;
|
||||
isPrivate: boolean;
|
||||
}) | null;
|
||||
} & {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
recipeId: number;
|
||||
productId: number | null;
|
||||
quantity: import("@prisma/client/runtime/library").Decimal | null;
|
||||
unit: string | null;
|
||||
rawName: string;
|
||||
rawLine: string | null;
|
||||
quantity: import("@prisma/client/runtime/library").Decimal | null;
|
||||
unit: string | null;
|
||||
note: string | null;
|
||||
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
|
||||
matchConfidence: number | null;
|
||||
matchSource: string | null;
|
||||
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
|
||||
recipeId: number;
|
||||
analysisStatus: string | null;
|
||||
}>;
|
||||
setVisibility(id: number, dto: SetRecipeVisibilityDto, user: {
|
||||
@@ -454,6 +499,8 @@ export declare class RecipesController {
|
||||
ingredients: ({
|
||||
product: ({
|
||||
nutrition: {
|
||||
id: number;
|
||||
productId: number;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
@@ -461,54 +508,52 @@ export declare class RecipesController {
|
||||
salt: number | null;
|
||||
sugar: number | null;
|
||||
fiber: number | null;
|
||||
id: number;
|
||||
productId: number;
|
||||
} | null;
|
||||
} & {
|
||||
category: string | null;
|
||||
status: string;
|
||||
name: string;
|
||||
categoryId: number | null;
|
||||
canonicalName: string | null;
|
||||
id: number;
|
||||
normalizedName: string;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
name: string;
|
||||
ownerId: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number;
|
||||
status: string;
|
||||
normalizedName: string;
|
||||
category: string | null;
|
||||
canonicalName: string | null;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
categoryId: number | null;
|
||||
isPrivate: boolean;
|
||||
}) | null;
|
||||
} & {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
recipeId: number;
|
||||
productId: number | null;
|
||||
quantity: import("@prisma/client/runtime/library").Decimal | null;
|
||||
unit: string | null;
|
||||
rawName: string;
|
||||
rawLine: string | null;
|
||||
quantity: import("@prisma/client/runtime/library").Decimal | null;
|
||||
unit: string | null;
|
||||
note: string | null;
|
||||
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
|
||||
matchConfidence: number | null;
|
||||
matchSource: string | null;
|
||||
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
|
||||
recipeId: number;
|
||||
analysisStatus: string | null;
|
||||
})[];
|
||||
shares: {
|
||||
userId: number;
|
||||
}[];
|
||||
} & {
|
||||
isPublic: boolean;
|
||||
name: string;
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
imageUrl: string | null;
|
||||
servings: number | null;
|
||||
isPublic: boolean;
|
||||
ownerId: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
shareRecipe(id: number, dto: ShareRecipeDto, user: {
|
||||
userId: number;
|
||||
@@ -520,6 +565,8 @@ export declare class RecipesController {
|
||||
ingredients: ({
|
||||
product: ({
|
||||
nutrition: {
|
||||
id: number;
|
||||
productId: number;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
@@ -527,54 +574,52 @@ export declare class RecipesController {
|
||||
salt: number | null;
|
||||
sugar: number | null;
|
||||
fiber: number | null;
|
||||
id: number;
|
||||
productId: number;
|
||||
} | null;
|
||||
} & {
|
||||
category: string | null;
|
||||
status: string;
|
||||
name: string;
|
||||
categoryId: number | null;
|
||||
canonicalName: string | null;
|
||||
id: number;
|
||||
normalizedName: string;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
name: string;
|
||||
ownerId: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number;
|
||||
status: string;
|
||||
normalizedName: string;
|
||||
category: string | null;
|
||||
canonicalName: string | null;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
categoryId: number | null;
|
||||
isPrivate: boolean;
|
||||
}) | null;
|
||||
} & {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
recipeId: number;
|
||||
productId: number | null;
|
||||
quantity: import("@prisma/client/runtime/library").Decimal | null;
|
||||
unit: string | null;
|
||||
rawName: string;
|
||||
rawLine: string | null;
|
||||
quantity: import("@prisma/client/runtime/library").Decimal | null;
|
||||
unit: string | null;
|
||||
note: string | null;
|
||||
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
|
||||
matchConfidence: number | null;
|
||||
matchSource: string | null;
|
||||
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
|
||||
recipeId: number;
|
||||
analysisStatus: string | null;
|
||||
})[];
|
||||
shares: {
|
||||
userId: number;
|
||||
}[];
|
||||
} & {
|
||||
isPublic: boolean;
|
||||
name: string;
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
imageUrl: string | null;
|
||||
servings: number | null;
|
||||
isPublic: boolean;
|
||||
ownerId: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
unshareRecipe(id: number, username: string, user: {
|
||||
userId: number;
|
||||
@@ -586,6 +631,8 @@ export declare class RecipesController {
|
||||
ingredients: ({
|
||||
product: ({
|
||||
nutrition: {
|
||||
id: number;
|
||||
productId: number;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
@@ -593,54 +640,52 @@ export declare class RecipesController {
|
||||
salt: number | null;
|
||||
sugar: number | null;
|
||||
fiber: number | null;
|
||||
id: number;
|
||||
productId: number;
|
||||
} | null;
|
||||
} & {
|
||||
category: string | null;
|
||||
status: string;
|
||||
name: string;
|
||||
categoryId: number | null;
|
||||
canonicalName: string | null;
|
||||
id: number;
|
||||
normalizedName: string;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
name: string;
|
||||
ownerId: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number;
|
||||
status: string;
|
||||
normalizedName: string;
|
||||
category: string | null;
|
||||
canonicalName: string | null;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
categoryId: number | null;
|
||||
isPrivate: boolean;
|
||||
}) | null;
|
||||
} & {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
recipeId: number;
|
||||
productId: number | null;
|
||||
quantity: import("@prisma/client/runtime/library").Decimal | null;
|
||||
unit: string | null;
|
||||
rawName: string;
|
||||
rawLine: string | null;
|
||||
quantity: import("@prisma/client/runtime/library").Decimal | null;
|
||||
unit: string | null;
|
||||
note: string | null;
|
||||
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
|
||||
matchConfidence: number | null;
|
||||
matchSource: string | null;
|
||||
alternativeProductIds: import("@prisma/client/runtime/library").JsonValue | null;
|
||||
recipeId: number;
|
||||
analysisStatus: string | null;
|
||||
})[];
|
||||
shares: {
|
||||
userId: number;
|
||||
}[];
|
||||
} & {
|
||||
isPublic: boolean;
|
||||
name: string;
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
imageUrl: string | null;
|
||||
servings: number | null;
|
||||
isPublic: boolean;
|
||||
ownerId: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
}
|
||||
export {};
|
||||
|
||||
+16
-2
@@ -22,6 +22,7 @@ const parse_markdown_dto_1 = require("./dto/parse-markdown.dto");
|
||||
const current_user_decorator_1 = require("../auth/decorators/current-user.decorator");
|
||||
const share_recipe_dto_1 = require("./dto/share-recipe.dto");
|
||||
const set_recipe_visibility_dto_1 = require("./dto/set-recipe-visibility.dto");
|
||||
const recipe_analysis_service_1 = require("./recipe-analysis.service");
|
||||
class UpdateImageDto {
|
||||
}
|
||||
__decorate([
|
||||
@@ -29,8 +30,9 @@ __decorate([
|
||||
__metadata("design:type", String)
|
||||
], UpdateImageDto.prototype, "sourceUrl", void 0);
|
||||
let RecipesController = class RecipesController {
|
||||
constructor(recipesService) {
|
||||
constructor(recipesService, recipeAnalysisService) {
|
||||
this.recipesService = recipesService;
|
||||
this.recipeAnalysisService = recipeAnalysisService;
|
||||
}
|
||||
parseMarkdown(dto) {
|
||||
return this.recipesService.parseMarkdown(dto);
|
||||
@@ -44,6 +46,9 @@ let RecipesController = class RecipesController {
|
||||
getInventoryPreview(id, user) {
|
||||
return this.recipesService.getInventoryPreview(id, user.userId);
|
||||
}
|
||||
getRecipeAnalysis(id, user) {
|
||||
return this.recipeAnalysisService.analyzeRecipeIngredients(id, user.userId);
|
||||
}
|
||||
findOne(id, user) {
|
||||
return this.recipesService.findOne(id, user.userId);
|
||||
}
|
||||
@@ -102,6 +107,14 @@ __decorate([
|
||||
__metadata("design:paramtypes", [Number, Object]),
|
||||
__metadata("design:returntype", void 0)
|
||||
], RecipesController.prototype, "getInventoryPreview", null);
|
||||
__decorate([
|
||||
(0, common_1.Get)(':id/analysis'),
|
||||
__param(0, (0, common_1.Param)('id', common_1.ParseIntPipe)),
|
||||
__param(1, (0, current_user_decorator_1.CurrentUser)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Number, Object]),
|
||||
__metadata("design:returntype", void 0)
|
||||
], RecipesController.prototype, "getRecipeAnalysis", null);
|
||||
__decorate([
|
||||
(0, common_1.Get)(':id'),
|
||||
__param(0, (0, common_1.Param)('id', common_1.ParseIntPipe)),
|
||||
@@ -183,6 +196,7 @@ __decorate([
|
||||
], RecipesController.prototype, "unshareRecipe", null);
|
||||
exports.RecipesController = RecipesController = __decorate([
|
||||
(0, common_1.Controller)('recipes'),
|
||||
__metadata("design:paramtypes", [recipes_service_1.RecipesService])
|
||||
__metadata("design:paramtypes", [recipes_service_1.RecipesService,
|
||||
recipe_analysis_service_1.RecipeAnalysisService])
|
||||
], RecipesController);
|
||||
//# sourceMappingURL=recipes.controller.js.map
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"recipes.controller.js","sourceRoot":"","sources":["../../src/recipes/recipes.controller.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"recipes.controller.js","sourceRoot":"","sources":["../../src/recipes/recipes.controller.ts"],"names":[],"mappings":"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"}
|
||||
+2
-1
@@ -12,6 +12,7 @@ const prisma_module_1 = require("../prisma/prisma.module");
|
||||
const ai_module_1 = require("../ai/ai.module");
|
||||
const recipes_controller_1 = require("./recipes.controller");
|
||||
const recipes_service_1 = require("./recipes.service");
|
||||
const recipe_analysis_service_1 = require("./recipe-analysis.service");
|
||||
let RecipesModule = class RecipesModule {
|
||||
};
|
||||
exports.RecipesModule = RecipesModule;
|
||||
@@ -19,7 +20,7 @@ exports.RecipesModule = RecipesModule = __decorate([
|
||||
(0, common_1.Module)({
|
||||
imports: [prisma_module_1.PrismaModule, ai_module_1.AiModule],
|
||||
controllers: [recipes_controller_1.RecipesController],
|
||||
providers: [recipes_service_1.RecipesService],
|
||||
providers: [recipes_service_1.RecipesService, recipe_analysis_service_1.RecipeAnalysisService],
|
||||
})
|
||||
], RecipesModule);
|
||||
//# sourceMappingURL=recipes.module.js.map
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"recipes.module.js","sourceRoot":"","sources":["../../src/recipes/recipes.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,2DAAuD;AACvD,+CAA2C;AAC3C,6DAAyD;AACzD,uDAAmD;AAO5C,IAAM,aAAa,GAAnB,MAAM,aAAa;CAAG,CAAA;AAAhB,sCAAa;wBAAb,aAAa;IALzB,IAAA,eAAM,EAAC;QACN,OAAO,EAAE,CAAC,4BAAY,EAAE,oBAAQ,CAAC;QACjC,WAAW,EAAE,CAAC,sCAAiB,CAAC;QAChC,SAAS,EAAE,CAAC,gCAAc,CAAC;KAC5B,CAAC;GACW,aAAa,CAAG"}
|
||||
{"version":3,"file":"recipes.module.js","sourceRoot":"","sources":["../../src/recipes/recipes.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,2DAAuD;AACvD,+CAA2C;AAC3C,6DAAyD;AACzD,uDAAmD;AACnD,uEAAkE;AAO3D,IAAM,aAAa,GAAnB,MAAM,aAAa;CAAG,CAAA;AAAhB,sCAAa;wBAAb,aAAa;IALzB,IAAA,eAAM,EAAC;QACN,OAAO,EAAE,CAAC,4BAAY,EAAE,oBAAQ,CAAC;QACjC,WAAW,EAAE,CAAC,sCAAiB,CAAC;QAChC,SAAS,EAAE,CAAC,gCAAc,EAAE,+CAAqB,CAAC;KACnD,CAAC;GACW,aAAa,CAAG"}
|
||||
+176
-175
@@ -17,6 +17,7 @@ export declare class RecipesService {
|
||||
private readonly logger;
|
||||
constructor(prisma: PrismaService, aiService: AiService);
|
||||
private throwRecipeNotFound;
|
||||
private normalizeIngredientName;
|
||||
private assertProductsActive;
|
||||
private findRecipeByIdOrThrow;
|
||||
private assertAndClaimRecipeOwner;
|
||||
@@ -73,6 +74,8 @@ export declare class RecipesService {
|
||||
ingredients: ({
|
||||
product: ({
|
||||
nutrition: {
|
||||
id: number;
|
||||
productId: number;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
@@ -80,54 +83,52 @@ export declare class RecipesService {
|
||||
salt: number | null;
|
||||
sugar: number | null;
|
||||
fiber: number | null;
|
||||
id: number;
|
||||
productId: number;
|
||||
} | null;
|
||||
} & {
|
||||
category: string | null;
|
||||
status: string;
|
||||
name: string;
|
||||
categoryId: number | null;
|
||||
canonicalName: string | null;
|
||||
id: number;
|
||||
normalizedName: string;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
name: string;
|
||||
ownerId: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number;
|
||||
status: string;
|
||||
normalizedName: string;
|
||||
category: string | null;
|
||||
canonicalName: string | null;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
categoryId: number | null;
|
||||
isPrivate: boolean;
|
||||
}) | null;
|
||||
} & {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
recipeId: number;
|
||||
productId: number | null;
|
||||
quantity: Prisma.Decimal | null;
|
||||
unit: string | null;
|
||||
rawName: string;
|
||||
rawLine: string | null;
|
||||
quantity: Prisma.Decimal | null;
|
||||
unit: string | null;
|
||||
note: string | null;
|
||||
alternativeProductIds: Prisma.JsonValue | null;
|
||||
matchConfidence: number | null;
|
||||
matchSource: string | null;
|
||||
alternativeProductIds: Prisma.JsonValue | null;
|
||||
recipeId: number;
|
||||
analysisStatus: string | null;
|
||||
})[];
|
||||
shares: {
|
||||
userId: number;
|
||||
}[];
|
||||
} & {
|
||||
isPublic: boolean;
|
||||
name: string;
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
imageUrl: string | null;
|
||||
servings: number | null;
|
||||
isPublic: boolean;
|
||||
ownerId: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
})[]>;
|
||||
findOne(id: number, userId: number): Promise<{
|
||||
owner: {
|
||||
@@ -137,6 +138,8 @@ export declare class RecipesService {
|
||||
ingredients: ({
|
||||
product: ({
|
||||
nutrition: {
|
||||
id: number;
|
||||
productId: number;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
@@ -144,59 +147,59 @@ export declare class RecipesService {
|
||||
salt: number | null;
|
||||
sugar: number | null;
|
||||
fiber: number | null;
|
||||
id: number;
|
||||
productId: number;
|
||||
} | null;
|
||||
} & {
|
||||
category: string | null;
|
||||
status: string;
|
||||
name: string;
|
||||
categoryId: number | null;
|
||||
canonicalName: string | null;
|
||||
id: number;
|
||||
normalizedName: string;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
name: string;
|
||||
ownerId: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number;
|
||||
status: string;
|
||||
normalizedName: string;
|
||||
category: string | null;
|
||||
canonicalName: string | null;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
categoryId: number | null;
|
||||
isPrivate: boolean;
|
||||
}) | null;
|
||||
} & {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
recipeId: number;
|
||||
productId: number | null;
|
||||
quantity: Prisma.Decimal | null;
|
||||
unit: string | null;
|
||||
rawName: string;
|
||||
rawLine: string | null;
|
||||
quantity: Prisma.Decimal | null;
|
||||
unit: string | null;
|
||||
note: string | null;
|
||||
alternativeProductIds: Prisma.JsonValue | null;
|
||||
matchConfidence: number | null;
|
||||
matchSource: string | null;
|
||||
alternativeProductIds: Prisma.JsonValue | null;
|
||||
recipeId: number;
|
||||
analysisStatus: string | null;
|
||||
})[];
|
||||
shares: {
|
||||
userId: number;
|
||||
}[];
|
||||
} & {
|
||||
isPublic: boolean;
|
||||
name: string;
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
imageUrl: string | null;
|
||||
servings: number | null;
|
||||
isPublic: boolean;
|
||||
ownerId: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
update(id: number, updateRecipeDto: CreateRecipeDto, userId: number): Promise<{
|
||||
ingredients: ({
|
||||
product: ({
|
||||
nutrition: {
|
||||
id: number;
|
||||
productId: number;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
@@ -204,51 +207,49 @@ export declare class RecipesService {
|
||||
salt: number | null;
|
||||
sugar: number | null;
|
||||
fiber: number | null;
|
||||
id: number;
|
||||
productId: number;
|
||||
} | null;
|
||||
} & {
|
||||
category: string | null;
|
||||
status: string;
|
||||
name: string;
|
||||
categoryId: number | null;
|
||||
canonicalName: string | null;
|
||||
id: number;
|
||||
normalizedName: string;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
name: string;
|
||||
ownerId: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number;
|
||||
status: string;
|
||||
normalizedName: string;
|
||||
category: string | null;
|
||||
canonicalName: string | null;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
categoryId: number | null;
|
||||
isPrivate: boolean;
|
||||
}) | null;
|
||||
} & {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
recipeId: number;
|
||||
productId: number | null;
|
||||
quantity: Prisma.Decimal | null;
|
||||
unit: string | null;
|
||||
rawName: string;
|
||||
rawLine: string | null;
|
||||
quantity: Prisma.Decimal | null;
|
||||
unit: string | null;
|
||||
note: string | null;
|
||||
alternativeProductIds: Prisma.JsonValue | null;
|
||||
matchConfidence: number | null;
|
||||
matchSource: string | null;
|
||||
alternativeProductIds: Prisma.JsonValue | null;
|
||||
recipeId: number;
|
||||
analysisStatus: string | null;
|
||||
})[];
|
||||
} & {
|
||||
isPublic: boolean;
|
||||
name: string;
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
imageUrl: string | null;
|
||||
servings: number | null;
|
||||
isPublic: boolean;
|
||||
ownerId: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
remove(id: number, userId: number): Promise<void>;
|
||||
updateImage(id: number, sourceUrl: string, userId: number): Promise<{
|
||||
@@ -259,6 +260,8 @@ export declare class RecipesService {
|
||||
ingredients: ({
|
||||
product: ({
|
||||
nutrition: {
|
||||
id: number;
|
||||
productId: number;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
@@ -266,54 +269,52 @@ export declare class RecipesService {
|
||||
salt: number | null;
|
||||
sugar: number | null;
|
||||
fiber: number | null;
|
||||
id: number;
|
||||
productId: number;
|
||||
} | null;
|
||||
} & {
|
||||
category: string | null;
|
||||
status: string;
|
||||
name: string;
|
||||
categoryId: number | null;
|
||||
canonicalName: string | null;
|
||||
id: number;
|
||||
normalizedName: string;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
name: string;
|
||||
ownerId: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number;
|
||||
status: string;
|
||||
normalizedName: string;
|
||||
category: string | null;
|
||||
canonicalName: string | null;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
categoryId: number | null;
|
||||
isPrivate: boolean;
|
||||
}) | null;
|
||||
} & {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
recipeId: number;
|
||||
productId: number | null;
|
||||
quantity: Prisma.Decimal | null;
|
||||
unit: string | null;
|
||||
rawName: string;
|
||||
rawLine: string | null;
|
||||
quantity: Prisma.Decimal | null;
|
||||
unit: string | null;
|
||||
note: string | null;
|
||||
alternativeProductIds: Prisma.JsonValue | null;
|
||||
matchConfidence: number | null;
|
||||
matchSource: string | null;
|
||||
alternativeProductIds: Prisma.JsonValue | null;
|
||||
recipeId: number;
|
||||
analysisStatus: string | null;
|
||||
})[];
|
||||
shares: {
|
||||
userId: number;
|
||||
}[];
|
||||
} & {
|
||||
isPublic: boolean;
|
||||
name: string;
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
imageUrl: string | null;
|
||||
servings: number | null;
|
||||
isPublic: boolean;
|
||||
ownerId: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
setVisibility(id: number, userId: number, isPublic: boolean): Promise<{
|
||||
owner: {
|
||||
@@ -323,6 +324,8 @@ export declare class RecipesService {
|
||||
ingredients: ({
|
||||
product: ({
|
||||
nutrition: {
|
||||
id: number;
|
||||
productId: number;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
@@ -330,54 +333,52 @@ export declare class RecipesService {
|
||||
salt: number | null;
|
||||
sugar: number | null;
|
||||
fiber: number | null;
|
||||
id: number;
|
||||
productId: number;
|
||||
} | null;
|
||||
} & {
|
||||
category: string | null;
|
||||
status: string;
|
||||
name: string;
|
||||
categoryId: number | null;
|
||||
canonicalName: string | null;
|
||||
id: number;
|
||||
normalizedName: string;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
name: string;
|
||||
ownerId: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number;
|
||||
status: string;
|
||||
normalizedName: string;
|
||||
category: string | null;
|
||||
canonicalName: string | null;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
categoryId: number | null;
|
||||
isPrivate: boolean;
|
||||
}) | null;
|
||||
} & {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
recipeId: number;
|
||||
productId: number | null;
|
||||
quantity: Prisma.Decimal | null;
|
||||
unit: string | null;
|
||||
rawName: string;
|
||||
rawLine: string | null;
|
||||
quantity: Prisma.Decimal | null;
|
||||
unit: string | null;
|
||||
note: string | null;
|
||||
alternativeProductIds: Prisma.JsonValue | null;
|
||||
matchConfidence: number | null;
|
||||
matchSource: string | null;
|
||||
alternativeProductIds: Prisma.JsonValue | null;
|
||||
recipeId: number;
|
||||
analysisStatus: string | null;
|
||||
})[];
|
||||
shares: {
|
||||
userId: number;
|
||||
}[];
|
||||
} & {
|
||||
isPublic: boolean;
|
||||
name: string;
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
imageUrl: string | null;
|
||||
servings: number | null;
|
||||
isPublic: boolean;
|
||||
ownerId: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
shareWithUser(id: number, ownerId: number, username: string): Promise<{
|
||||
owner: {
|
||||
@@ -387,6 +388,8 @@ export declare class RecipesService {
|
||||
ingredients: ({
|
||||
product: ({
|
||||
nutrition: {
|
||||
id: number;
|
||||
productId: number;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
@@ -394,54 +397,52 @@ export declare class RecipesService {
|
||||
salt: number | null;
|
||||
sugar: number | null;
|
||||
fiber: number | null;
|
||||
id: number;
|
||||
productId: number;
|
||||
} | null;
|
||||
} & {
|
||||
category: string | null;
|
||||
status: string;
|
||||
name: string;
|
||||
categoryId: number | null;
|
||||
canonicalName: string | null;
|
||||
id: number;
|
||||
normalizedName: string;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
name: string;
|
||||
ownerId: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number;
|
||||
status: string;
|
||||
normalizedName: string;
|
||||
category: string | null;
|
||||
canonicalName: string | null;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
categoryId: number | null;
|
||||
isPrivate: boolean;
|
||||
}) | null;
|
||||
} & {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
recipeId: number;
|
||||
productId: number | null;
|
||||
quantity: Prisma.Decimal | null;
|
||||
unit: string | null;
|
||||
rawName: string;
|
||||
rawLine: string | null;
|
||||
quantity: Prisma.Decimal | null;
|
||||
unit: string | null;
|
||||
note: string | null;
|
||||
alternativeProductIds: Prisma.JsonValue | null;
|
||||
matchConfidence: number | null;
|
||||
matchSource: string | null;
|
||||
alternativeProductIds: Prisma.JsonValue | null;
|
||||
recipeId: number;
|
||||
analysisStatus: string | null;
|
||||
})[];
|
||||
shares: {
|
||||
userId: number;
|
||||
}[];
|
||||
} & {
|
||||
isPublic: boolean;
|
||||
name: string;
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
imageUrl: string | null;
|
||||
servings: number | null;
|
||||
isPublic: boolean;
|
||||
ownerId: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
unshareWithUser(id: number, ownerId: number, username: string): Promise<{
|
||||
owner: {
|
||||
@@ -451,6 +452,8 @@ export declare class RecipesService {
|
||||
ingredients: ({
|
||||
product: ({
|
||||
nutrition: {
|
||||
id: number;
|
||||
productId: number;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
@@ -458,59 +461,59 @@ export declare class RecipesService {
|
||||
salt: number | null;
|
||||
sugar: number | null;
|
||||
fiber: number | null;
|
||||
id: number;
|
||||
productId: number;
|
||||
} | null;
|
||||
} & {
|
||||
category: string | null;
|
||||
status: string;
|
||||
name: string;
|
||||
categoryId: number | null;
|
||||
canonicalName: string | null;
|
||||
id: number;
|
||||
normalizedName: string;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
name: string;
|
||||
ownerId: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number;
|
||||
status: string;
|
||||
normalizedName: string;
|
||||
category: string | null;
|
||||
canonicalName: string | null;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
categoryId: number | null;
|
||||
isPrivate: boolean;
|
||||
}) | null;
|
||||
} & {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
recipeId: number;
|
||||
productId: number | null;
|
||||
quantity: Prisma.Decimal | null;
|
||||
unit: string | null;
|
||||
rawName: string;
|
||||
rawLine: string | null;
|
||||
quantity: Prisma.Decimal | null;
|
||||
unit: string | null;
|
||||
note: string | null;
|
||||
alternativeProductIds: Prisma.JsonValue | null;
|
||||
matchConfidence: number | null;
|
||||
matchSource: string | null;
|
||||
alternativeProductIds: Prisma.JsonValue | null;
|
||||
recipeId: number;
|
||||
analysisStatus: string | null;
|
||||
})[];
|
||||
shares: {
|
||||
userId: number;
|
||||
}[];
|
||||
} & {
|
||||
isPublic: boolean;
|
||||
name: string;
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
imageUrl: string | null;
|
||||
servings: number | null;
|
||||
isPublic: boolean;
|
||||
ownerId: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
create(createRecipeDto: CreateRecipeDto, userId: number): Promise<{
|
||||
ingredients: ({
|
||||
product: ({
|
||||
nutrition: {
|
||||
id: number;
|
||||
productId: number;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
@@ -518,55 +521,55 @@ export declare class RecipesService {
|
||||
salt: number | null;
|
||||
sugar: number | null;
|
||||
fiber: number | null;
|
||||
id: number;
|
||||
productId: number;
|
||||
} | null;
|
||||
} & {
|
||||
category: string | null;
|
||||
status: string;
|
||||
name: string;
|
||||
categoryId: number | null;
|
||||
canonicalName: string | null;
|
||||
id: number;
|
||||
normalizedName: string;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
name: string;
|
||||
ownerId: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number;
|
||||
status: string;
|
||||
normalizedName: string;
|
||||
category: string | null;
|
||||
canonicalName: string | null;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
categoryId: number | null;
|
||||
isPrivate: boolean;
|
||||
}) | null;
|
||||
} & {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
recipeId: number;
|
||||
productId: number | null;
|
||||
quantity: Prisma.Decimal | null;
|
||||
unit: string | null;
|
||||
rawName: string;
|
||||
rawLine: string | null;
|
||||
quantity: Prisma.Decimal | null;
|
||||
unit: string | null;
|
||||
note: string | null;
|
||||
alternativeProductIds: Prisma.JsonValue | null;
|
||||
matchConfidence: number | null;
|
||||
matchSource: string | null;
|
||||
alternativeProductIds: Prisma.JsonValue | null;
|
||||
recipeId: number;
|
||||
analysisStatus: string | null;
|
||||
})[];
|
||||
} & {
|
||||
isPublic: boolean;
|
||||
name: string;
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
imageUrl: string | null;
|
||||
servings: number | null;
|
||||
isPublic: boolean;
|
||||
ownerId: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
addIngredient(id: number, ingredient: CreateIngredientDto, userId: number): Promise<{
|
||||
product: ({
|
||||
nutrition: {
|
||||
id: number;
|
||||
productId: number;
|
||||
calories: number | null;
|
||||
protein: number | null;
|
||||
fat: number | null;
|
||||
@@ -574,38 +577,36 @@ export declare class RecipesService {
|
||||
salt: number | null;
|
||||
sugar: number | null;
|
||||
fiber: number | null;
|
||||
id: number;
|
||||
productId: number;
|
||||
} | null;
|
||||
} & {
|
||||
category: string | null;
|
||||
status: string;
|
||||
name: string;
|
||||
categoryId: number | null;
|
||||
canonicalName: string | null;
|
||||
id: number;
|
||||
normalizedName: string;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
name: string;
|
||||
ownerId: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerId: number;
|
||||
status: string;
|
||||
normalizedName: string;
|
||||
category: string | null;
|
||||
canonicalName: string | null;
|
||||
isActive: boolean;
|
||||
deletedAt: Date | null;
|
||||
categoryId: number | null;
|
||||
isPrivate: boolean;
|
||||
}) | null;
|
||||
} & {
|
||||
id: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
recipeId: number;
|
||||
productId: number | null;
|
||||
quantity: Prisma.Decimal | null;
|
||||
unit: string | null;
|
||||
rawName: string;
|
||||
rawLine: string | null;
|
||||
quantity: Prisma.Decimal | null;
|
||||
unit: string | null;
|
||||
note: string | null;
|
||||
alternativeProductIds: Prisma.JsonValue | null;
|
||||
matchConfidence: number | null;
|
||||
matchSource: string | null;
|
||||
alternativeProductIds: Prisma.JsonValue | null;
|
||||
recipeId: number;
|
||||
analysisStatus: string | null;
|
||||
}>;
|
||||
suggestRecipesFromInventory(userId: number): Promise<{
|
||||
|
||||
+8
-2
@@ -29,6 +29,12 @@ let RecipesService = RecipesService_1 = class RecipesService {
|
||||
throwRecipeNotFound(id) {
|
||||
throw new common_1.NotFoundException(`Recipe with id ${id} not found`);
|
||||
}
|
||||
normalizeIngredientName(value) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed)
|
||||
return trimmed;
|
||||
return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
|
||||
}
|
||||
async assertProductsActive(productIds) {
|
||||
if (productIds.length === 0)
|
||||
return;
|
||||
@@ -299,7 +305,7 @@ let RecipesService = RecipesService_1 = class RecipesService {
|
||||
ingredients: {
|
||||
create: updateRecipeDto.ingredients.map((ingredient) => ({
|
||||
productId: ingredient.productId ?? null,
|
||||
rawName: ingredient.rawName,
|
||||
rawName: this.normalizeIngredientName(ingredient.rawName),
|
||||
rawLine: ingredient.rawLine ?? null,
|
||||
quantity: ingredient.quantity ?? null,
|
||||
unit: ingredient.unit?.trim() ? ingredient.unit : null,
|
||||
@@ -443,7 +449,7 @@ let RecipesService = RecipesService_1 = class RecipesService {
|
||||
ingredients: {
|
||||
create: createRecipeDto.ingredients.map((ingredient) => ({
|
||||
productId: ingredient.productId ?? null,
|
||||
rawName: ingredient.rawName,
|
||||
rawName: this.normalizeIngredientName(ingredient.rawName),
|
||||
rawLine: ingredient.rawLine ?? null,
|
||||
quantity: ingredient.quantity ?? null,
|
||||
unit: ingredient.unit?.trim() ? ingredient.unit : null,
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,228 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { canConvert, convertUnit } from '../common/utils/units';
|
||||
|
||||
type AnalysisStatus = 'exact_match' | 'covered_by_pantry' | 'substitutable' | 'missing';
|
||||
|
||||
@Injectable()
|
||||
export class RecipeAnalysisService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private async getAccessibleRecipe(id: number, userId: number) {
|
||||
const recipe = await this.prisma.recipe.findFirst({
|
||||
where: {
|
||||
id,
|
||||
OR: [
|
||||
{ isPublic: true },
|
||||
{ ownerId: userId },
|
||||
{ shares: { some: { userId } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
ingredients: {
|
||||
include: {
|
||||
product: true,
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipe) {
|
||||
throw new NotFoundException(`Recipe with id ${id} not found`);
|
||||
}
|
||||
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private calculateAvailableQuantity(
|
||||
inventoryItems: Array<{ quantity: any; unit: string }>,
|
||||
requiredUnit: string,
|
||||
): number {
|
||||
if (!requiredUnit) {
|
||||
return inventoryItems.reduce((sum, item) => sum + Number(item.quantity ?? 0), 0);
|
||||
}
|
||||
|
||||
const normalizedRequiredUnit = requiredUnit.trim().toLowerCase();
|
||||
|
||||
const sameUnit = inventoryItems
|
||||
.filter((item) => item.unit.trim().toLowerCase() === normalizedRequiredUnit)
|
||||
.reduce((sum, item) => sum + Number(item.quantity ?? 0), 0);
|
||||
|
||||
const converted = inventoryItems
|
||||
.filter((item) => item.unit.trim().toLowerCase() !== normalizedRequiredUnit)
|
||||
.reduce((sum, item) => {
|
||||
if (!canConvert(item.unit, requiredUnit)) return sum;
|
||||
try {
|
||||
return sum + convertUnit(Number(item.quantity ?? 0), item.unit, requiredUnit);
|
||||
} catch {
|
||||
return sum;
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return sameUnit + converted;
|
||||
}
|
||||
|
||||
async analyzeRecipeIngredients(id: number, userId: number) {
|
||||
const recipe = await this.getAccessibleRecipe(id, userId);
|
||||
|
||||
const pantryItems = await this.prisma.pantryItem.findMany({
|
||||
where: { userId },
|
||||
select: { productId: true },
|
||||
});
|
||||
const pantryProductIds = new Set(pantryItems.map((p) => p.productId));
|
||||
|
||||
const ingredients = await Promise.all(
|
||||
recipe.ingredients.map(async (ingredient: any) => {
|
||||
const requiredQuantity = Number(ingredient.quantity ?? 0);
|
||||
const requiredUnit = (ingredient.unit ?? '').trim();
|
||||
const rawName = (ingredient.rawName ?? '').trim() || 'Okänd ingrediens';
|
||||
|
||||
if (!ingredient.productId || !ingredient.product) {
|
||||
return {
|
||||
ingredientId: ingredient.id,
|
||||
rawName,
|
||||
quantity: requiredQuantity,
|
||||
unit: requiredUnit,
|
||||
note: ingredient.note ?? null,
|
||||
status: 'missing' as AnalysisStatus,
|
||||
matchedProductId: null,
|
||||
matchedProductName: null,
|
||||
source: null,
|
||||
availableQuantity: 0,
|
||||
missingQuantity: requiredQuantity,
|
||||
};
|
||||
}
|
||||
|
||||
if (pantryProductIds.has(ingredient.productId)) {
|
||||
return {
|
||||
ingredientId: ingredient.id,
|
||||
rawName,
|
||||
quantity: requiredQuantity,
|
||||
unit: requiredUnit,
|
||||
note: ingredient.note ?? null,
|
||||
status: 'covered_by_pantry' as AnalysisStatus,
|
||||
matchedProductId: ingredient.productId,
|
||||
matchedProductName: ingredient.product.canonicalName || ingredient.product.name,
|
||||
source: 'pantry',
|
||||
availableQuantity: requiredQuantity,
|
||||
missingQuantity: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const inventoryItems = await this.prisma.inventoryItem.findMany({
|
||||
where: { productId: ingredient.productId },
|
||||
select: { quantity: true, unit: true },
|
||||
});
|
||||
|
||||
const availableQuantity = this.calculateAvailableQuantity(inventoryItems, requiredUnit);
|
||||
if (availableQuantity >= requiredQuantity) {
|
||||
return {
|
||||
ingredientId: ingredient.id,
|
||||
rawName,
|
||||
quantity: requiredQuantity,
|
||||
unit: requiredUnit,
|
||||
note: ingredient.note ?? null,
|
||||
status: 'exact_match' as AnalysisStatus,
|
||||
matchedProductId: ingredient.productId,
|
||||
matchedProductName: ingredient.product.canonicalName || ingredient.product.name,
|
||||
source: 'inventory',
|
||||
availableQuantity,
|
||||
missingQuantity: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const alternativeProductIds = Array.isArray(ingredient.alternativeProductIds)
|
||||
? ingredient.alternativeProductIds.filter((id: any) => typeof id === 'number')
|
||||
: [];
|
||||
|
||||
for (const altProductId of alternativeProductIds) {
|
||||
if (pantryProductIds.has(altProductId)) {
|
||||
const altProduct = await this.prisma.product.findUnique({
|
||||
where: { id: altProductId },
|
||||
select: { id: true, name: true, canonicalName: true },
|
||||
});
|
||||
|
||||
return {
|
||||
ingredientId: ingredient.id,
|
||||
rawName,
|
||||
quantity: requiredQuantity,
|
||||
unit: requiredUnit,
|
||||
note: ingredient.note ?? null,
|
||||
status: 'substitutable' as AnalysisStatus,
|
||||
matchedProductId: altProduct?.id ?? altProductId,
|
||||
matchedProductName: altProduct?.canonicalName || altProduct?.name || null,
|
||||
source: 'pantry_substitute',
|
||||
availableQuantity: requiredQuantity,
|
||||
missingQuantity: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const altInventoryItems = await this.prisma.inventoryItem.findMany({
|
||||
where: { productId: altProductId },
|
||||
select: { quantity: true, unit: true },
|
||||
});
|
||||
const altAvailable = this.calculateAvailableQuantity(altInventoryItems, requiredUnit);
|
||||
if (altAvailable > 0) {
|
||||
const altProduct = await this.prisma.product.findUnique({
|
||||
where: { id: altProductId },
|
||||
select: { id: true, name: true, canonicalName: true },
|
||||
});
|
||||
|
||||
return {
|
||||
ingredientId: ingredient.id,
|
||||
rawName,
|
||||
quantity: requiredQuantity,
|
||||
unit: requiredUnit,
|
||||
note: ingredient.note ?? null,
|
||||
status: 'substitutable' as AnalysisStatus,
|
||||
matchedProductId: altProduct?.id ?? altProductId,
|
||||
matchedProductName: altProduct?.canonicalName || altProduct?.name || null,
|
||||
source: 'inventory_substitute',
|
||||
availableQuantity: altAvailable,
|
||||
missingQuantity: Math.max(0, requiredQuantity - altAvailable),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ingredientId: ingredient.id,
|
||||
rawName,
|
||||
quantity: requiredQuantity,
|
||||
unit: requiredUnit,
|
||||
note: ingredient.note ?? null,
|
||||
status: 'missing' as AnalysisStatus,
|
||||
matchedProductId: ingredient.productId,
|
||||
matchedProductName: ingredient.product.canonicalName || ingredient.product.name,
|
||||
source: null,
|
||||
availableQuantity,
|
||||
missingQuantity: Math.max(0, requiredQuantity - availableQuantity),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const summary = {
|
||||
exactCount: ingredients.filter((i) => i.status === 'exact_match').length,
|
||||
pantryCount: ingredients.filter((i) => i.status === 'covered_by_pantry').length,
|
||||
substituteCount: ingredients.filter((i) => i.status === 'substitutable').length,
|
||||
missingCount: ingredients.filter((i) => i.status === 'missing').length,
|
||||
};
|
||||
|
||||
const shoppingListCandidates = ingredients
|
||||
.filter((i) => i.status === 'missing')
|
||||
.map((i) => ({
|
||||
ingredientId: i.ingredientId,
|
||||
rawName: i.rawName,
|
||||
quantity: i.quantity,
|
||||
unit: i.unit,
|
||||
missingQuantity: i.missingQuantity,
|
||||
}));
|
||||
|
||||
return {
|
||||
recipeId: recipe.id,
|
||||
ingredients,
|
||||
summary,
|
||||
shoppingListCandidates,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { ParseMarkdownDto } from './dto/parse-markdown.dto';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { ShareRecipeDto } from './dto/share-recipe.dto';
|
||||
import { SetRecipeVisibilityDto } from './dto/set-recipe-visibility.dto';
|
||||
import { RecipeAnalysisService } from './recipe-analysis.service';
|
||||
|
||||
class UpdateImageDto {
|
||||
@IsString()
|
||||
@@ -15,7 +16,10 @@ class UpdateImageDto {
|
||||
|
||||
@Controller('recipes')
|
||||
export class RecipesController {
|
||||
constructor(private readonly recipesService: RecipesService) {}
|
||||
constructor(
|
||||
private readonly recipesService: RecipesService,
|
||||
private readonly recipeAnalysisService: RecipeAnalysisService,
|
||||
) {}
|
||||
|
||||
@Post('parse-markdown')
|
||||
parseMarkdown(@Body() dto: ParseMarkdownDto) {
|
||||
@@ -40,6 +44,14 @@ export class RecipesController {
|
||||
return this.recipesService.getInventoryPreview(id, user.userId);
|
||||
}
|
||||
|
||||
@Get(':id/analysis')
|
||||
getRecipeAnalysis(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@CurrentUser() user: { userId: number },
|
||||
) {
|
||||
return this.recipeAnalysisService.analyzeRecipeIngredients(id, user.userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
|
||||
@@ -3,10 +3,11 @@ import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { AiModule } from '../ai/ai.module';
|
||||
import { RecipesController } from './recipes.controller';
|
||||
import { RecipesService } from './recipes.service';
|
||||
import { RecipeAnalysisService } from './recipe-analysis.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AiModule],
|
||||
controllers: [RecipesController],
|
||||
providers: [RecipesService],
|
||||
providers: [RecipesService, RecipeAnalysisService],
|
||||
})
|
||||
export class RecipesModule {}
|
||||
@@ -34,6 +34,12 @@ export class RecipesService {
|
||||
throw new NotFoundException(`Recipe with id ${id} not found`);
|
||||
}
|
||||
|
||||
private normalizeIngredientName(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`;
|
||||
}
|
||||
|
||||
private async assertProductsActive(productIds: number[]): Promise<void> {
|
||||
if (productIds.length === 0) return;
|
||||
const activeProducts = await this.prisma.product.findMany({
|
||||
@@ -361,7 +367,7 @@ export class RecipesService {
|
||||
ingredients: {
|
||||
create: updateRecipeDto.ingredients.map((ingredient) => ({
|
||||
productId: ingredient.productId ?? null,
|
||||
rawName: ingredient.rawName,
|
||||
rawName: this.normalizeIngredientName(ingredient.rawName),
|
||||
rawLine: ingredient.rawLine ?? null,
|
||||
quantity: ingredient.quantity ?? null,
|
||||
unit: ingredient.unit?.trim() ? ingredient.unit : null,
|
||||
@@ -537,7 +543,7 @@ export class RecipesService {
|
||||
ingredients: {
|
||||
create: createRecipeDto.ingredients.map((ingredient) => ({
|
||||
productId: ingredient.productId ?? null,
|
||||
rawName: ingredient.rawName,
|
||||
rawName: this.normalizeIngredientName(ingredient.rawName),
|
||||
rawLine: ingredient.rawLine ?? null,
|
||||
quantity: ingredient.quantity ?? null,
|
||||
unit: ingredient.unit?.trim() ? ingredient.unit : null,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -45,6 +45,7 @@ class RecipeApiPaths {
|
||||
static String share(int id) => '/recipes/$id/share';
|
||||
static String unshare(int id, String username) => '/recipes/$id/share/${Uri.encodeComponent(username)}';
|
||||
static String inventoryPreview(int id) => '/recipes/$id/inventory-preview';
|
||||
static String analysis(int id) => '/recipes/$id/analysis';
|
||||
static const parseMarkdown = '/recipes/parse-markdown';
|
||||
static const aiSuggestions = '/recipes/ai-suggestions';
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../../core/api/guarded_api_call.dart';
|
||||
import '../../../features/auth/data/auth_providers.dart';
|
||||
import '../domain/recipe.dart';
|
||||
import '../domain/inventory_preview.dart';
|
||||
import '../domain/recipe_analysis.dart';
|
||||
import 'recipe_repository.dart';
|
||||
|
||||
final recipeRepositoryProvider = Provider<RecipeRepository>((ref) {
|
||||
@@ -38,3 +39,14 @@ final inventoryPreviewProvider =
|
||||
.fetchInventoryPreview(id, token: token),
|
||||
);
|
||||
});
|
||||
|
||||
final recipeAnalysisProvider =
|
||||
FutureProvider.family<RecipeAnalysis, int>((ref, id) async {
|
||||
final token = await ref.watch(authStateProvider.future);
|
||||
return guardedApiCall(
|
||||
ref,
|
||||
() => ref
|
||||
.read(recipeRepositoryProvider)
|
||||
.fetchRecipeAnalysis(id, token: token),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../../../core/api/api_paths.dart';
|
||||
import '../domain/parsed_recipe.dart';
|
||||
import '../domain/recipe.dart';
|
||||
import '../domain/inventory_preview.dart';
|
||||
import '../domain/recipe_analysis.dart';
|
||||
|
||||
class RecipeRepository {
|
||||
final ApiClient _api;
|
||||
@@ -174,6 +175,27 @@ class RecipeRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<RecipeAnalysis> fetchRecipeAnalysis(int id,
|
||||
{String? token}) async {
|
||||
try {
|
||||
final data = await _api.getJson(
|
||||
RecipeApiPaths.analysis(id),
|
||||
token: token,
|
||||
);
|
||||
if (data is! Map<String, dynamic>) {
|
||||
throw const ApiException(
|
||||
type: ApiErrorType.unknown, message: 'Ogiltigt svar från servern.');
|
||||
}
|
||||
return RecipeAnalysis.fromJson(data);
|
||||
} on ApiException {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
throw const ApiException(
|
||||
type: ApiErrorType.network,
|
||||
message: 'Kunde inte hämta receptanalys.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<ParsedRecipe> parseMarkdown(String markdown,
|
||||
{String? token}) async {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
enum RecipeIngredientAvailabilityStatus {
|
||||
exactMatch,
|
||||
coveredByPantry,
|
||||
substitutable,
|
||||
missing,
|
||||
}
|
||||
|
||||
class RecipeIngredientAnalysis {
|
||||
final int ingredientId;
|
||||
final String rawName;
|
||||
final double quantity;
|
||||
final String unit;
|
||||
final String? note;
|
||||
final RecipeIngredientAvailabilityStatus status;
|
||||
final int? matchedProductId;
|
||||
final String? matchedProductName;
|
||||
final String? source;
|
||||
final double availableQuantity;
|
||||
final double missingQuantity;
|
||||
|
||||
const RecipeIngredientAnalysis({
|
||||
required this.ingredientId,
|
||||
required this.rawName,
|
||||
required this.quantity,
|
||||
required this.unit,
|
||||
this.note,
|
||||
required this.status,
|
||||
this.matchedProductId,
|
||||
this.matchedProductName,
|
||||
this.source,
|
||||
required this.availableQuantity,
|
||||
required this.missingQuantity,
|
||||
});
|
||||
|
||||
factory RecipeIngredientAnalysis.fromJson(Map<String, dynamic> json) {
|
||||
final rawStatus = json['status'] as String? ?? 'missing';
|
||||
final status = switch (rawStatus) {
|
||||
'exact_match' => RecipeIngredientAvailabilityStatus.exactMatch,
|
||||
'covered_by_pantry' => RecipeIngredientAvailabilityStatus.coveredByPantry,
|
||||
'substitutable' => RecipeIngredientAvailabilityStatus.substitutable,
|
||||
_ => RecipeIngredientAvailabilityStatus.missing,
|
||||
};
|
||||
|
||||
return RecipeIngredientAnalysis(
|
||||
ingredientId: (json['ingredientId'] as num?)?.toInt() ?? 0,
|
||||
rawName: (json['rawName'] as String? ?? '').trim(),
|
||||
quantity: (json['quantity'] as num? ?? 0).toDouble(),
|
||||
unit: json['unit'] as String? ?? '',
|
||||
note: json['note'] as String?,
|
||||
status: status,
|
||||
matchedProductId: (json['matchedProductId'] as num?)?.toInt(),
|
||||
matchedProductName: json['matchedProductName'] as String?,
|
||||
source: json['source'] as String?,
|
||||
availableQuantity: (json['availableQuantity'] as num? ?? 0).toDouble(),
|
||||
missingQuantity: (json['missingQuantity'] as num? ?? 0).toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RecipeAnalysisSummary {
|
||||
final int exactCount;
|
||||
final int pantryCount;
|
||||
final int substituteCount;
|
||||
final int missingCount;
|
||||
|
||||
const RecipeAnalysisSummary({
|
||||
required this.exactCount,
|
||||
required this.pantryCount,
|
||||
required this.substituteCount,
|
||||
required this.missingCount,
|
||||
});
|
||||
|
||||
factory RecipeAnalysisSummary.fromJson(Map<String, dynamic> json) {
|
||||
return RecipeAnalysisSummary(
|
||||
exactCount: (json['exactCount'] as num?)?.toInt() ?? 0,
|
||||
pantryCount: (json['pantryCount'] as num?)?.toInt() ?? 0,
|
||||
substituteCount: (json['substituteCount'] as num?)?.toInt() ?? 0,
|
||||
missingCount: (json['missingCount'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RecipeShoppingCandidate {
|
||||
final int ingredientId;
|
||||
final String rawName;
|
||||
final double quantity;
|
||||
final String unit;
|
||||
final double missingQuantity;
|
||||
|
||||
const RecipeShoppingCandidate({
|
||||
required this.ingredientId,
|
||||
required this.rawName,
|
||||
required this.quantity,
|
||||
required this.unit,
|
||||
required this.missingQuantity,
|
||||
});
|
||||
|
||||
factory RecipeShoppingCandidate.fromJson(Map<String, dynamic> json) {
|
||||
return RecipeShoppingCandidate(
|
||||
ingredientId: (json['ingredientId'] as num?)?.toInt() ?? 0,
|
||||
rawName: (json['rawName'] as String? ?? '').trim(),
|
||||
quantity: (json['quantity'] as num? ?? 0).toDouble(),
|
||||
unit: json['unit'] as String? ?? '',
|
||||
missingQuantity: (json['missingQuantity'] as num? ?? 0).toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RecipeAnalysis {
|
||||
final int recipeId;
|
||||
final List<RecipeIngredientAnalysis> ingredients;
|
||||
final RecipeAnalysisSummary summary;
|
||||
final List<RecipeShoppingCandidate> shoppingListCandidates;
|
||||
|
||||
const RecipeAnalysis({
|
||||
required this.recipeId,
|
||||
required this.ingredients,
|
||||
required this.summary,
|
||||
required this.shoppingListCandidates,
|
||||
});
|
||||
|
||||
factory RecipeAnalysis.fromJson(Map<String, dynamic> json) {
|
||||
final rawIngredients = json['ingredients'] as List<dynamic>? ?? const [];
|
||||
final rawShopping = json['shoppingListCandidates'] as List<dynamic>? ?? const [];
|
||||
|
||||
return RecipeAnalysis(
|
||||
recipeId: (json['recipeId'] as num?)?.toInt() ?? 0,
|
||||
ingredients: rawIngredients
|
||||
.map((e) => RecipeIngredientAnalysis.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
summary: RecipeAnalysisSummary.fromJson(
|
||||
json['summary'] as Map<String, dynamic>? ?? const {},
|
||||
),
|
||||
shoppingListCandidates: rawShopping
|
||||
.map((e) => RecipeShoppingCandidate.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/api/api_error_mapper.dart';
|
||||
import '../../../core/api/api_exception.dart';
|
||||
import '../../../core/api/api_paths.dart';
|
||||
import '../../../core/api/api_providers.dart';
|
||||
import '../../../core/utils/formatters.dart';
|
||||
import '../../../core/l10n/l10n.dart';
|
||||
import '../../auth/data/auth_providers.dart';
|
||||
@@ -14,19 +12,6 @@ import '../domain/parsed_recipe.dart';
|
||||
|
||||
enum _Step { input, review }
|
||||
|
||||
class _ManualIngredient {
|
||||
int? productId;
|
||||
final TextEditingController qtyCtrl = TextEditingController();
|
||||
final TextEditingController unitCtrl = TextEditingController(text: 'g');
|
||||
final TextEditingController noteCtrl = TextEditingController();
|
||||
|
||||
void dispose() {
|
||||
qtyCtrl.dispose();
|
||||
unitCtrl.dispose();
|
||||
noteCtrl.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class CreateRecipeScreen extends ConsumerStatefulWidget {
|
||||
/// Optional markdown to pre-fill the input field, e.g. from import.
|
||||
final String? initialMarkdown;
|
||||
@@ -64,20 +49,12 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
|
||||
late TextEditingController _nameCtrl;
|
||||
late TextEditingController _servingsCtrl;
|
||||
late List<bool> _included;
|
||||
late Map<int, int?> _selectedProductIds;
|
||||
late Map<int, String?> _selectedProductNames;
|
||||
|
||||
late Map<int, TextEditingController> _rawNameControllers;
|
||||
late Map<int, TextEditingController> _qtyControllers;
|
||||
late Map<int, TextEditingController> _unitControllers;
|
||||
late Map<int, TextEditingController> _noteControllers;
|
||||
|
||||
// Produktlista för manuellt tillagda ingredienser
|
||||
List<Map<String, dynamic>> _allProducts = [];
|
||||
bool _isLoadingProducts = false;
|
||||
|
||||
// Manuellt tillagda ingredienser
|
||||
final List<_ManualIngredient> _manualIngredients = [];
|
||||
|
||||
bool _isSaving = false;
|
||||
String? _saveError;
|
||||
|
||||
@@ -87,64 +64,37 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
|
||||
if (_step == _Step.review) {
|
||||
_nameCtrl.dispose();
|
||||
_servingsCtrl.dispose();
|
||||
for (final c in _rawNameControllers.values) c.dispose();
|
||||
for (final c in _qtyControllers.values) c.dispose();
|
||||
for (final c in _unitControllers.values) c.dispose();
|
||||
for (final c in _noteControllers.values) c.dispose();
|
||||
for (final m in _manualIngredients) m.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadProducts() async {
|
||||
setState(() => _isLoadingProducts = true);
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.getJson(ProductApiPaths.list, token: token);
|
||||
if (!mounted) return;
|
||||
final products = (data as List<dynamic>)
|
||||
.map((e) => e as Map<String, dynamic>)
|
||||
.toList()
|
||||
..sort((a, b) {
|
||||
final aName = (a['canonicalName'] ?? a['name'] ?? '').toString();
|
||||
final bName = (b['canonicalName'] ?? b['name'] ?? '').toString();
|
||||
return aName.toLowerCase().compareTo(bName.toLowerCase());
|
||||
});
|
||||
setState(() {
|
||||
_allProducts = products;
|
||||
_isLoadingProducts = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoadingProducts = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _initReviewState(ParsedRecipe parsed) {
|
||||
_nameCtrl = TextEditingController(text: parsed.name);
|
||||
_servingsCtrl = TextEditingController();
|
||||
_included = List.generate(parsed.ingredients.length, (_) => true);
|
||||
_selectedProductIds = {};
|
||||
_selectedProductNames = {};
|
||||
_rawNameControllers = {};
|
||||
_qtyControllers = {};
|
||||
_unitControllers = {};
|
||||
_noteControllers = {};
|
||||
for (var i = 0; i < parsed.ingredients.length; i++) {
|
||||
final ing = parsed.ingredients[i];
|
||||
_rawNameControllers[i] = TextEditingController(text: ing.rawName);
|
||||
_qtyControllers[i] = TextEditingController(
|
||||
text: ing.quantity > 0 ? formatQuantity(ing.quantity) : '',
|
||||
);
|
||||
_unitControllers[i] = TextEditingController(text: ing.unit);
|
||||
_noteControllers[i] = TextEditingController(text: ing.note ?? '');
|
||||
if (ing.suggestions.isNotEmpty) {
|
||||
_selectedProductIds[i] = ing.suggestions.first.productId;
|
||||
_selectedProductNames[i] = ing.suggestions.first.productName;
|
||||
} else {
|
||||
_selectedProductIds[i] = null;
|
||||
_selectedProductNames[i] = null;
|
||||
}
|
||||
}
|
||||
_loadProducts();
|
||||
|
||||
String _formatIngredientName(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return trimmed;
|
||||
return '${trimmed[0].toUpperCase()}${trimmed.substring(1)}';
|
||||
}
|
||||
|
||||
Future<void> _parseMarkdown() async {
|
||||
@@ -190,7 +140,10 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
|
||||
final ingredients = <Map<String, dynamic>>[];
|
||||
for (var i = 0; i < _parsed!.ingredients.length; i++) {
|
||||
if (!_included[i]) continue;
|
||||
final productId = _selectedProductIds[i];
|
||||
final rawName = _formatIngredientName(_rawNameControllers[i]!.text);
|
||||
if (rawName.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final qty = double.tryParse(
|
||||
_qtyControllers[i]!.text.trim().replaceAll(',', '.'),
|
||||
) ??
|
||||
@@ -198,40 +151,18 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
|
||||
final unit = _unitControllers[i]!.text.trim();
|
||||
final note = _noteControllers[i]!.text.trim();
|
||||
final ing = _parsed!.ingredients[i];
|
||||
// Alternativa produkter: alla suggestions vars productId matchar ett alternativ
|
||||
final alternativeProductIds = ing.alternatives.length > 1
|
||||
? ing.suggestions
|
||||
.where((s) => s.productId != productId)
|
||||
.map((s) => s.productId)
|
||||
.toList()
|
||||
: <int>[];
|
||||
ingredients.add({
|
||||
'rawName': ing.rawName,
|
||||
'rawName': rawName,
|
||||
if ((ing.rawLine ?? '').trim().isNotEmpty) 'rawLine': ing.rawLine,
|
||||
if (productId != null) 'productId': productId,
|
||||
if (qty > 0) 'quantity': qty,
|
||||
if (unit.isNotEmpty) 'unit': unit,
|
||||
if (note.isNotEmpty) 'note': note,
|
||||
if (alternativeProductIds.isNotEmpty)
|
||||
'alternativeProductIds': alternativeProductIds,
|
||||
});
|
||||
}
|
||||
// Inkludera manuellt tillagda ingredienser
|
||||
for (final manual in _manualIngredients) {
|
||||
if (manual.productId == null) continue;
|
||||
final qty = double.tryParse(
|
||||
manual.qtyCtrl.text.trim().replaceAll(',', '.'),
|
||||
);
|
||||
if (qty == null) continue;
|
||||
final unit = manual.unitCtrl.text.trim();
|
||||
if (unit.isEmpty) continue;
|
||||
final note = manual.noteCtrl.text.trim();
|
||||
ingredients.add({
|
||||
'productId': manual.productId,
|
||||
'quantity': qty,
|
||||
'unit': unit,
|
||||
if (note.isNotEmpty) 'note': note,
|
||||
});
|
||||
|
||||
if (ingredients.isEmpty) {
|
||||
setState(() => _saveError = 'Lägg till minst en ingrediensrad.');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
@@ -370,27 +301,6 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
|
||||
parsed.ingredients.length,
|
||||
(i) => _buildIngredientRow(i, parsed.ingredients[i])),
|
||||
],
|
||||
// Manuellt tillagda ingredienser
|
||||
if (_manualIngredients.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
...List.generate(
|
||||
_manualIngredients.length,
|
||||
(i) => _buildManualIngredientCard(i),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
// Knapp för att lägga till ingrediens
|
||||
if (_isLoadingProducts)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 4),
|
||||
child: LinearProgressIndicator(),
|
||||
)
|
||||
else
|
||||
OutlinedButton.icon(
|
||||
onPressed: _addManualIngredient,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Lägg till ingrediens'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
@@ -423,124 +333,11 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _addManualIngredient() {
|
||||
setState(() {
|
||||
_manualIngredients.add(_ManualIngredient());
|
||||
});
|
||||
}
|
||||
|
||||
void _removeManualIngredient(int index) {
|
||||
setState(() {
|
||||
_manualIngredients[index].dispose();
|
||||
_manualIngredients.removeAt(index);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildManualIngredientCard(int index) {
|
||||
final manual = _manualIngredients[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Tillagd ingrediens',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () => _removeManualIngredient(index),
|
||||
tooltip: 'Ta bort',
|
||||
),
|
||||
],
|
||||
),
|
||||
DropdownButtonFormField<int>(
|
||||
value: manual.productId,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Produkt *',
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
hint: const Text('Välj produkt'),
|
||||
items: _allProducts
|
||||
.map((p) => DropdownMenuItem<int>(
|
||||
value: p['id'] as int,
|
||||
child: Text(
|
||||
((p['canonicalName'] ?? p['name']) as Object).toString(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() => manual.productId = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: TextField(
|
||||
controller: manual.qtyCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Mängd',
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding:
|
||||
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
),
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: TextField(
|
||||
controller: manual.unitCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Enhet',
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding:
|
||||
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: manual.noteCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Not',
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding:
|
||||
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIngredientRow(int index, ParsedIngredient ing) {
|
||||
final isIncluded = _included[index];
|
||||
final noProductFound = ing.suggestions.isEmpty;
|
||||
// Problem #2: tydlig varning om rad är inkluderad men saknar produkt
|
||||
final showMissingProductWarning = isIncluded && noProductFound;
|
||||
final suggestionText = ing.suggestions.isEmpty
|
||||
? null
|
||||
: 'Förslag: ${ing.suggestions.take(3).map((s) => s.productName).join(', ')}';
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
@@ -562,44 +359,34 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
|
||||
))
|
||||
.toList(),
|
||||
)
|
||||
: Text(ing.rawName),
|
||||
subtitle: noProductFound
|
||||
? Text(
|
||||
context.l10n.recipeCreateNoProductFound,
|
||||
: Text(_formatIngredientName(ing.rawName)),
|
||||
subtitle: suggestionText == null
|
||||
? null
|
||||
: Text(
|
||||
suggestionText,
|
||||
style: TextStyle(
|
||||
color: showMissingProductWarning
|
||||
? Theme.of(context).colorScheme.error
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
),
|
||||
)
|
||||
: DropdownButton<int>(
|
||||
value: _selectedProductIds[index],
|
||||
isExpanded: true,
|
||||
onChanged: isIncluded
|
||||
? (id) {
|
||||
if (id == null) return;
|
||||
setState(() {
|
||||
_selectedProductIds[index] = id;
|
||||
_selectedProductNames[index] = ing.suggestions
|
||||
.firstWhere((s) => s.productId == id)
|
||||
.productName;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
items: ing.suggestions
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s.productId,
|
||||
child: Text(s.productName),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
// Problem #1: editerbara qty/unit/note-fält per ingrediens
|
||||
if (isIncluded)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _rawNameControllers[index],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Ingrediens',
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding:
|
||||
EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 72,
|
||||
@@ -645,6 +432,8 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -11,7 +11,7 @@ import '../../../core/ui/async_state_views.dart';
|
||||
import '../../auth/data/auth_providers.dart';
|
||||
import '../data/recipe_providers.dart';
|
||||
import '../domain/recipe.dart';
|
||||
import '../domain/inventory_preview.dart';
|
||||
import '../domain/recipe_analysis.dart';
|
||||
|
||||
String _fmtQty(double v) => formatQuantity(v);
|
||||
|
||||
@@ -126,7 +126,7 @@ class RecipeDetailScreen extends ConsumerWidget {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.45),
|
||||
color: Colors.black.withValues(alpha: 0.45),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Text(
|
||||
@@ -296,7 +296,7 @@ class _ImagePlaceholder extends StatelessWidget {
|
||||
child: Icon(
|
||||
Icons.restaurant,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.4),
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -543,7 +543,7 @@ class _InventoryPreviewSectionState
|
||||
tooltip: 'Uppdatera',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
ref.invalidate(inventoryPreviewProvider(widget.recipeId));
|
||||
ref.invalidate(recipeAnalysisProvider(widget.recipeId));
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -561,7 +561,7 @@ class _InventoryPreviewResults extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final previewAsync = ref.watch(inventoryPreviewProvider(recipeId));
|
||||
final previewAsync = ref.watch(recipeAnalysisProvider(recipeId));
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return previewAsync.when(
|
||||
@@ -587,6 +587,33 @@ class _InventoryPreviewResults extends ConsumerWidget {
|
||||
...preview.ingredients.map(
|
||||
(ing) => _IngredientPreviewRow(ingredient: ing),
|
||||
),
|
||||
if (preview.shoppingListCandidates.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text('Shoppinglista', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
...preview.shoppingListCandidates.map((item) {
|
||||
final qty = _fmtQty(item.missingQuantity > 0 ? item.missingQuantity : item.quantity);
|
||||
final measure = '$qty ${item.unit}'.trim();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.shopping_cart_outlined, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
measure.isEmpty
|
||||
? item.rawName
|
||||
: '$measure ${item.rawName}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -595,7 +622,7 @@ class _InventoryPreviewResults extends ConsumerWidget {
|
||||
}
|
||||
|
||||
class _SummaryChips extends StatelessWidget {
|
||||
final PreviewSummary summary;
|
||||
final RecipeAnalysisSummary summary;
|
||||
|
||||
const _SummaryChips({required this.summary});
|
||||
|
||||
@@ -607,7 +634,7 @@ class _SummaryChips extends StatelessWidget {
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
if (summary.canCookExactly)
|
||||
if (summary.missingCount == 0)
|
||||
Chip(
|
||||
avatar: Icon(Icons.check_circle,
|
||||
color: cs.onPrimary, size: 16),
|
||||
@@ -624,21 +651,29 @@ class _SummaryChips extends StatelessWidget {
|
||||
backgroundColor: cs.errorContainer,
|
||||
labelStyle: TextStyle(color: cs.onErrorContainer),
|
||||
),
|
||||
if (summary.unitMismatchCount > 0)
|
||||
if (summary.substituteCount > 0)
|
||||
Chip(
|
||||
avatar: Icon(Icons.swap_horiz,
|
||||
color: cs.onTertiaryContainer, size: 16),
|
||||
label: Text('${summary.unitMismatchCount} enhetsmismatch'),
|
||||
label: Text('${summary.substituteCount} ersättningsbar'),
|
||||
backgroundColor: cs.tertiaryContainer,
|
||||
labelStyle: TextStyle(color: cs.onTertiaryContainer),
|
||||
),
|
||||
if (summary.pantryCount > 0)
|
||||
Chip(
|
||||
avatar: Icon(Icons.kitchen_outlined,
|
||||
color: cs.onSecondaryContainer, size: 16),
|
||||
label: Text('${summary.pantryCount} i skafferiet'),
|
||||
backgroundColor: cs.secondaryContainer,
|
||||
labelStyle: TextStyle(color: cs.onSecondaryContainer),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IngredientPreviewRow extends StatelessWidget {
|
||||
final IngredientPreview ingredient;
|
||||
final RecipeIngredientAnalysis ingredient;
|
||||
|
||||
const _IngredientPreviewRow({required this.ingredient});
|
||||
|
||||
@@ -646,36 +681,34 @@ class _IngredientPreviewRow extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
final label = ingredient.productName.trim().isEmpty
|
||||
? 'Okänd ingrediens'
|
||||
: ingredient.productName;
|
||||
final matchedName = ingredient.matchedProductName?.trim() ?? '';
|
||||
final label = matchedName.isEmpty
|
||||
? (ingredient.rawName.trim().isEmpty ? 'Okänd ingrediens' : ingredient.rawName)
|
||||
: matchedName;
|
||||
|
||||
final (icon, color) = ingredient.fromPantry
|
||||
? (Icons.kitchen_outlined, cs.secondary)
|
||||
: switch (ingredient.status) {
|
||||
IngredientStatus.enough => (Icons.check_circle_outline, cs.primary),
|
||||
IngredientStatus.unitMismatch => (
|
||||
Icons.swap_horiz,
|
||||
cs.tertiary,
|
||||
),
|
||||
IngredientStatus.missing => (Icons.cancel_outlined, cs.error),
|
||||
final (icon, color) = switch (ingredient.status) {
|
||||
RecipeIngredientAvailabilityStatus.coveredByPantry => (Icons.kitchen_outlined, cs.secondary),
|
||||
RecipeIngredientAvailabilityStatus.exactMatch => (Icons.check_circle_outline, cs.primary),
|
||||
RecipeIngredientAvailabilityStatus.substitutable => (Icons.swap_horiz, cs.tertiary),
|
||||
RecipeIngredientAvailabilityStatus.missing => (Icons.cancel_outlined, cs.error),
|
||||
};
|
||||
|
||||
final effectiveUnit = ingredient.unit;
|
||||
final requiredStr =
|
||||
'${_fmtQty(ingredient.requiredQuantity)} ${ingredient.requiredUnit}'.trim();
|
||||
'${_fmtQty(ingredient.quantity)} $effectiveUnit'.trim();
|
||||
final availableStr =
|
||||
'${_fmtQty(ingredient.availableQuantity)} ${ingredient.requiredUnit}'.trim();
|
||||
'${_fmtQty(ingredient.availableQuantity)} $effectiveUnit'.trim();
|
||||
|
||||
final subtitle = ingredient.fromPantry
|
||||
? 'Finns i skafferiet'
|
||||
: switch (ingredient.status) {
|
||||
IngredientStatus.enough => 'Tillgängligt: $availableStr',
|
||||
IngredientStatus.missing => ingredient.availableQuantity > 0
|
||||
? 'Saknar ${_fmtQty(ingredient.missingQuantity)} ${ingredient.requiredUnit} '
|
||||
'(har $availableStr)'
|
||||
final subtitle = switch (ingredient.status) {
|
||||
RecipeIngredientAvailabilityStatus.coveredByPantry => 'Finns i skafferiet',
|
||||
RecipeIngredientAvailabilityStatus.exactMatch => 'Tillgängligt: $availableStr',
|
||||
RecipeIngredientAvailabilityStatus.substitutable =>
|
||||
ingredient.matchedProductName == null || ingredient.matchedProductName!.trim().isEmpty
|
||||
? 'Kan ersättas med annan vara'
|
||||
: 'Kan ersättas med ${ingredient.matchedProductName}',
|
||||
RecipeIngredientAvailabilityStatus.missing => ingredient.availableQuantity > 0
|
||||
? 'Saknar ${_fmtQty(ingredient.missingQuantity)} $effectiveUnit (har $availableStr)'
|
||||
: 'Saknas helt',
|
||||
IngredientStatus.unitMismatch =>
|
||||
'Annan enhet i lager – kontrollera manuellt',
|
||||
};
|
||||
|
||||
return Padding(
|
||||
|
||||
@@ -152,7 +152,7 @@ class _RecipeImageCard extends StatelessWidget {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.45),
|
||||
color: Colors.black.withValues(alpha: 0.45),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
|
||||
Reference in New Issue
Block a user