feat: add rematch functionality for recipe ingredients and enhance inventory management
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
- Added a new API path for rematching recipe ingredients in `api_paths.dart`. - Implemented a manual product creation dialog in `inventory_screen.dart` to allow users to create new products directly. - Integrated the rematch functionality in `recipe_repository.dart` to handle rematching of recipe ingredients. - Updated the recipe detail screen to include a button for triggering the rematch process. - Introduced a new `RecipeMatchingService` in the backend to handle ingredient matching logic. - Added database migration to include `aiEngineEnabled` column in the User table. Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -13,10 +13,128 @@ export type CategorySuggestion = {
|
||||
usedFallback: boolean;
|
||||
};
|
||||
|
||||
export type AiIngredientMatchSuggestion = {
|
||||
productId: number;
|
||||
reason?: string;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
};
|
||||
|
||||
export type AiSubstitutionSuggestion = {
|
||||
productId: number;
|
||||
reason?: string;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
private readonly logger = new Logger(AiService.name);
|
||||
|
||||
async suggestIngredientMatches(
|
||||
rawIngredient: string,
|
||||
candidates: Array<{ id: number; name: string; canonicalName?: string | null }>,
|
||||
): Promise<AiIngredientMatchSuggestion[]> {
|
||||
const apiKey = process.env.MISTRAL_API_KEY;
|
||||
if (!apiKey || candidates.length === 0) return [];
|
||||
|
||||
const candidateList = candidates
|
||||
.map((c) => `[${c.id}] ${c.canonicalName || c.name}`)
|
||||
.join('\n');
|
||||
|
||||
const systemPrompt = `Du matchar en ingrediensrad mot produktkandidater.
|
||||
Svara ENDAST med JSON: {"matches":[{"productId":123,"reason":"...","confidence":"high|medium|low"}]}
|
||||
Regler:
|
||||
1. Välj max 3 kandidater.
|
||||
2. Om inget passar, returnera tom lista.`;
|
||||
|
||||
const userPrompt = `Ingrediens: "${rawIngredient}"\nKandidater:\n${candidateList}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(MISTRAL_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
max_tokens: 300,
|
||||
temperature: 0.1,
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn(`suggestIngredientMatches API-fel: ${response.status}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { choices: { message: { content: string } }[] };
|
||||
const raw = data.choices?.[0]?.message?.content ?? '{}';
|
||||
const parsed = JSON.parse(raw) as { matches?: AiIngredientMatchSuggestion[] };
|
||||
return Array.isArray(parsed.matches) ? parsed.matches.slice(0, 3) : [];
|
||||
} catch (err) {
|
||||
this.logger.warn(`suggestIngredientMatches misslyckades: ${String(err)}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async suggestSubstitutions(
|
||||
rawIngredient: string,
|
||||
availableProducts: Array<{ id: number; name: string; canonicalName?: string | null }>,
|
||||
): Promise<AiSubstitutionSuggestion[]> {
|
||||
const apiKey = process.env.MISTRAL_API_KEY;
|
||||
if (!apiKey || availableProducts.length === 0) return [];
|
||||
|
||||
const productList = availableProducts
|
||||
.map((p) => `[${p.id}] ${p.canonicalName || p.name}`)
|
||||
.join('\n');
|
||||
|
||||
const systemPrompt = `Du föreslår ersättningsvaror för en ingrediens.
|
||||
Svara ENDAST med JSON: {"substitutions":[{"productId":123,"reason":"...","confidence":"high|medium|low"}]}
|
||||
Regler:
|
||||
1. Välj max 3 ersättningar.
|
||||
2. Om inget passar, returnera tom lista.`;
|
||||
|
||||
const userPrompt = `Ingrediens: "${rawIngredient}"\nTillgängliga produkter:\n${productList}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(MISTRAL_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
max_tokens: 300,
|
||||
temperature: 0.2,
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn(`suggestSubstitutions API-fel: ${response.status}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { choices: { message: { content: string } }[] };
|
||||
const raw = data.choices?.[0]?.message?.content ?? '{}';
|
||||
const parsed = JSON.parse(raw) as { substitutions?: AiSubstitutionSuggestion[] };
|
||||
return Array.isArray(parsed.substitutions) ? parsed.substitutions.slice(0, 3) : [];
|
||||
} catch (err) {
|
||||
this.logger.warn(`suggestSubstitutions misslyckades: ${String(err)}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async suggestCategory(
|
||||
productName: string,
|
||||
categories: FlatCategory[],
|
||||
|
||||
@@ -159,7 +159,7 @@ export class ReceiptImportService {
|
||||
const matched = await this.matchProducts(rawItems, userId);
|
||||
|
||||
// Steg 3: Regel + AI-kategorisering för alla användare
|
||||
return this.enrichWithAiCategories(matched);
|
||||
return this.enrichWithAiCategories(matched, userId);
|
||||
}
|
||||
|
||||
private async parseReceiptViaImporter(file: Express.Multer.File): Promise<ParsedReceiptItem[]> {
|
||||
@@ -341,7 +341,7 @@ export class ReceiptImportService {
|
||||
return best?.product;
|
||||
}
|
||||
|
||||
private async enrichWithAiCategories(items: ParsedReceiptItem[]): Promise<ParsedReceiptItem[]> {
|
||||
private async enrichWithAiCategories(items: ParsedReceiptItem[], userId?: number): Promise<ParsedReceiptItem[]> {
|
||||
let categories: Awaited<ReturnType<CategoriesService['findFlattened']>>;
|
||||
try {
|
||||
categories = await this.categoriesService.findFlattened();
|
||||
@@ -349,6 +349,13 @@ export class ReceiptImportService {
|
||||
return items; // Om kategoritjänsten är otillgänglig, returnera utan AI-förslag
|
||||
}
|
||||
|
||||
const user = userId
|
||||
? await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { aiEngineEnabled: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
const enriched: ParsedReceiptItem[] = [];
|
||||
for (const item of items) {
|
||||
if (!item.rawName) {
|
||||
@@ -424,9 +431,13 @@ export class ReceiptImportService {
|
||||
|
||||
// AI används som fallback när varken matchning eller regler satte kategori
|
||||
if (!nextSuggestion) {
|
||||
pushTrace('ai invoked');
|
||||
nextSuggestion = await this.aiService.suggestCategory(item.rawName, categories);
|
||||
pushTrace(`ai result -> "${nextSuggestion.path}" (${nextSuggestion.confidence})`);
|
||||
if (user?.aiEngineEnabled) {
|
||||
pushTrace('ai invoked');
|
||||
nextSuggestion = await this.aiService.suggestCategory(item.rawName, categories);
|
||||
pushTrace(`ai result -> "${nextSuggestion.path}" (${nextSuggestion.confidence})`);
|
||||
} else {
|
||||
pushTrace('ai skipped, feature disabled');
|
||||
}
|
||||
} else {
|
||||
pushTrace(`ai skipped, current -> "${nextSuggestion.path}"`);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { canConvert, convertUnit } from '../common/utils/units';
|
||||
import { AiService } from '../ai/ai.service';
|
||||
|
||||
type AnalysisStatus = 'exact_match' | 'covered_by_pantry' | 'substitutable' | 'missing';
|
||||
|
||||
@Injectable()
|
||||
export class RecipeAnalysisService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly aiService: AiService,
|
||||
) {}
|
||||
|
||||
private async getAccessibleRecipe(id: number, userId: number) {
|
||||
const recipe = await this.prisma.recipe.findFirst({
|
||||
@@ -66,12 +70,31 @@ export class RecipeAnalysisService {
|
||||
async analyzeRecipeIngredients(id: number, userId: number) {
|
||||
const recipe = await this.getAccessibleRecipe(id, userId);
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { aiEngineEnabled: true },
|
||||
});
|
||||
|
||||
const pantryItems = await this.prisma.pantryItem.findMany({
|
||||
where: { userId },
|
||||
select: { productId: true },
|
||||
});
|
||||
const pantryProductIds = new Set(pantryItems.map((p) => p.productId));
|
||||
|
||||
const userInventory = await this.prisma.inventoryItem.findMany({
|
||||
select: { productId: true },
|
||||
});
|
||||
const availableProductIds = new Set<number>([
|
||||
...pantryItems.map((p) => p.productId),
|
||||
...userInventory.map((i) => i.productId),
|
||||
]);
|
||||
const availableProducts = availableProductIds.size > 0
|
||||
? await this.prisma.product.findMany({
|
||||
where: { id: { in: Array.from(availableProductIds) }, isActive: true },
|
||||
select: { id: true, name: true, canonicalName: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const ingredients = await Promise.all(
|
||||
recipe.ingredients.map(async (ingredient: any) => {
|
||||
const requiredQuantity = Number(ingredient.quantity ?? 0);
|
||||
@@ -79,6 +102,25 @@ export class RecipeAnalysisService {
|
||||
const rawName = (ingredient.rawName ?? '').trim() || 'Okänd ingrediens';
|
||||
|
||||
if (!ingredient.productId || !ingredient.product) {
|
||||
const aiMatches = user?.aiEngineEnabled ? await this.aiService.suggestIngredientMatches(rawName, availableProducts) : [];
|
||||
const aiBest = aiMatches[0];
|
||||
if (aiBest) {
|
||||
const matched = availableProducts.find((p) => p.id === aiBest.productId);
|
||||
return {
|
||||
ingredientId: ingredient.id,
|
||||
rawName,
|
||||
quantity: requiredQuantity,
|
||||
unit: requiredUnit,
|
||||
note: ingredient.note ?? null,
|
||||
status: 'substitutable' as AnalysisStatus,
|
||||
matchedProductId: aiBest.productId,
|
||||
matchedProductName: matched?.canonicalName || matched?.name || null,
|
||||
source: 'ai_match',
|
||||
availableQuantity: 0,
|
||||
missingQuantity: requiredQuantity,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ingredientId: ingredient.id,
|
||||
rawName,
|
||||
@@ -185,6 +227,25 @@ export class RecipeAnalysisService {
|
||||
}
|
||||
}
|
||||
|
||||
const aiSubs = user?.aiEngineEnabled ? await this.aiService.suggestSubstitutions(rawName, availableProducts) : [];
|
||||
const aiBestSub = aiSubs[0];
|
||||
if (aiBestSub) {
|
||||
const aiProduct = availableProducts.find((p) => p.id === aiBestSub.productId);
|
||||
return {
|
||||
ingredientId: ingredient.id,
|
||||
rawName,
|
||||
quantity: requiredQuantity,
|
||||
unit: requiredUnit,
|
||||
note: ingredient.note ?? null,
|
||||
status: 'substitutable' as AnalysisStatus,
|
||||
matchedProductId: aiBestSub.productId,
|
||||
matchedProductName: aiProduct?.canonicalName || aiProduct?.name || null,
|
||||
source: 'ai_substitute',
|
||||
availableQuantity,
|
||||
missingQuantity: Math.max(0, requiredQuantity - availableQuantity),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ingredientId: ingredient.id,
|
||||
rawName,
|
||||
@@ -225,4 +286,8 @@ export class RecipeAnalysisService {
|
||||
shoppingListCandidates,
|
||||
};
|
||||
}
|
||||
|
||||
async rematchRecipeIngredients(id: number, userId: number) {
|
||||
return this.analyzeRecipeIngredients(id, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
export type ProductMatchCandidate = {
|
||||
id: number;
|
||||
name: string;
|
||||
canonicalName: string | null;
|
||||
normalizedName: string;
|
||||
};
|
||||
|
||||
export type IngredientSuggestion = {
|
||||
productId: number;
|
||||
productName: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RecipeMatchingService {
|
||||
private normalize(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-zåäö0-9\s]/gi, '')
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
private levenshtein(a: string, b: string): number {
|
||||
const m = a.length;
|
||||
const n = b.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, (_, i) =>
|
||||
Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
|
||||
);
|
||||
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
dp[i][j] =
|
||||
a[i - 1] === b[j - 1]
|
||||
? dp[i - 1][j - 1]
|
||||
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
return dp[m][n];
|
||||
}
|
||||
|
||||
private scoreProducts(query: string, products: ProductMatchCandidate[]) {
|
||||
const normalizedQuery = this.normalize(query);
|
||||
|
||||
return products
|
||||
.map((product) => {
|
||||
const targetName = this.normalize(product.canonicalName || product.name);
|
||||
const targetNormalized = this.normalize(product.normalizedName);
|
||||
|
||||
if (targetNormalized === normalizedQuery || targetName === normalizedQuery) {
|
||||
return { product, score: 100 };
|
||||
}
|
||||
|
||||
if (targetName.includes(normalizedQuery) || normalizedQuery.includes(targetName)) {
|
||||
return { product, score: 70 };
|
||||
}
|
||||
|
||||
const dist = this.levenshtein(normalizedQuery, targetName);
|
||||
const maxLen = Math.max(normalizedQuery.length, targetName.length);
|
||||
const similarity = maxLen === 0 ? 100 : Math.round((1 - dist / maxLen) * 100);
|
||||
|
||||
return { product, score: similarity };
|
||||
})
|
||||
.filter((s) => s.score >= 40)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
buildIngredientSuggestions(
|
||||
rawName: string,
|
||||
alternatives: string[] | undefined,
|
||||
products: ProductMatchCandidate[],
|
||||
): IngredientSuggestion[] {
|
||||
const variants = alternatives && alternatives.length > 1 ? alternatives : [rawName];
|
||||
|
||||
const seenIds = new Set<number>();
|
||||
|
||||
return variants
|
||||
.flatMap((variant) => this.scoreProducts(variant, products))
|
||||
.filter((s) => {
|
||||
if (seenIds.has(s.product.id)) return false;
|
||||
seenIds.add(s.product.id);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 5)
|
||||
.map((s) => ({
|
||||
productId: s.product.id,
|
||||
productName: s.product.canonicalName || s.product.name,
|
||||
score: s.score,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,14 @@ export class RecipesController {
|
||||
return this.recipeAnalysisService.analyzeRecipeIngredients(id, user.userId);
|
||||
}
|
||||
|
||||
@Post(':id/rematch')
|
||||
rematchRecipeIngredients(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@CurrentUser() user: { userId: number },
|
||||
) {
|
||||
return this.recipeAnalysisService.rematchRecipeIngredients(id, user.userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
|
||||
@@ -4,10 +4,11 @@ import { AiModule } from '../ai/ai.module';
|
||||
import { RecipesController } from './recipes.controller';
|
||||
import { RecipesService } from './recipes.service';
|
||||
import { RecipeAnalysisService } from './recipe-analysis.service';
|
||||
import { RecipeMatchingService } from './recipe-matching.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AiModule],
|
||||
controllers: [RecipesController],
|
||||
providers: [RecipesService, RecipeAnalysisService],
|
||||
providers: [RecipesService, RecipeAnalysisService, RecipeMatchingService],
|
||||
})
|
||||
export class RecipesModule {}
|
||||
@@ -9,7 +9,8 @@ import { CreateIngredientDto } from './dto/create-ingredient.dto';
|
||||
import { ParseMarkdownDto } from './dto/parse-markdown.dto';
|
||||
import { downloadAndOptimizeImage } from '../common/utils/download-image';
|
||||
import { parseRecipeMarkdown, ParsedRecipe, ParsedIngredient } from '../common/utils/recipe-parser';
|
||||
import { normalizeUnit, getUnitType, convertUnit, canConvert } from '../common/utils/units';
|
||||
import { convertUnit, canConvert } from '../common/utils/units';
|
||||
import { RecipeMatchingService } from './recipe-matching.service';
|
||||
|
||||
const IMAGE_DEST_DIR = process.env.IMAGE_DEST_DIR || '/app/recipe-images';
|
||||
|
||||
@@ -28,6 +29,7 @@ export class RecipesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly aiService: AiService,
|
||||
private readonly recipeMatchingService: RecipeMatchingService,
|
||||
) {}
|
||||
|
||||
private throwRecipeNotFound(id: number): never {
|
||||
@@ -721,76 +723,12 @@ Regler:
|
||||
select: { id: true, name: true, canonicalName: true, normalizedName: true },
|
||||
});
|
||||
|
||||
// Normalisera en sträng för jämförelse (lowercase, trim, ta bort skiljetecken)
|
||||
const normalize = (s: string) =>
|
||||
s.toLowerCase().trim().replace(/[^a-zåäö0-9\s]/gi, '').replace(/\s+/g, ' ');
|
||||
|
||||
// Enkel Levenshtein-distans
|
||||
const levenshtein = (a: string, b: string): number => {
|
||||
const m = a.length;
|
||||
const n = b.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, (_, i) =>
|
||||
Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
|
||||
);
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
dp[i][j] =
|
||||
a[i - 1] === b[j - 1]
|
||||
? dp[i - 1][j - 1]
|
||||
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
||||
}
|
||||
}
|
||||
return dp[m][n];
|
||||
};
|
||||
|
||||
const ingredientsWithSuggestions = parsed.ingredients.map((ingredient: ParsedIngredient) => {
|
||||
// Kör matchning mot alla alternativ och slå ihop suggestions
|
||||
const alternatives = ingredient.alternatives?.length > 1
|
||||
? ingredient.alternatives
|
||||
: [ingredient.rawName];
|
||||
|
||||
const scoreProduct = (query: string) => allProducts
|
||||
.map((product) => {
|
||||
const targetName = normalize(product.canonicalName || product.name);
|
||||
const targetNormalized = normalize(product.normalizedName);
|
||||
|
||||
// Exakt träff på normalizedName prioriteras
|
||||
if (targetNormalized === query || targetName === query) {
|
||||
return { product, score: 100 };
|
||||
}
|
||||
|
||||
// Delsträng-match
|
||||
if (targetName.includes(query) || query.includes(targetName)) {
|
||||
return { product, score: 70 };
|
||||
}
|
||||
|
||||
// Levenshtein-baserad likhet
|
||||
const dist = levenshtein(query, targetName);
|
||||
const maxLen = Math.max(query.length, targetName.length);
|
||||
const similarity = maxLen === 0 ? 100 : Math.round((1 - dist / maxLen) * 100);
|
||||
|
||||
return { product, score: similarity };
|
||||
})
|
||||
.filter((s) => s.score >= 40)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 5);
|
||||
|
||||
// Slå ihop suggestions från alla alternativ, deduplicera på productId, ta topp 5
|
||||
const seenIds = new Set<number>();
|
||||
const scored = alternatives
|
||||
.flatMap((alt) => scoreProduct(normalize(alt)))
|
||||
.filter((s) => {
|
||||
if (seenIds.has(s.product.id)) return false;
|
||||
seenIds.add(s.product.id);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 5)
|
||||
.map((s) => ({
|
||||
productId: s.product.id,
|
||||
productName: s.product.canonicalName || s.product.name,
|
||||
score: s.score,
|
||||
}));
|
||||
const scored = this.recipeMatchingService.buildIngredientSuggestions(
|
||||
ingredient.rawName,
|
||||
ingredient.alternatives,
|
||||
allProducts,
|
||||
);
|
||||
|
||||
return {
|
||||
rawName: ingredient.rawName,
|
||||
|
||||
@@ -19,6 +19,11 @@ class SetRecipeSharingDto {
|
||||
canShareRecipes: boolean;
|
||||
}
|
||||
|
||||
class SetAiEngineEnabledDto {
|
||||
@IsBoolean()
|
||||
aiEngineEnabled: boolean;
|
||||
}
|
||||
|
||||
class AdminCreateUserDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@@ -128,6 +133,16 @@ export class UsersController {
|
||||
return { id: updated.id, username: updated.username, canShareRecipes: updated.canShareRecipes };
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Patch(':id/ai-engine')
|
||||
async setAiEngineEnabled(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: SetAiEngineEnabledDto,
|
||||
) {
|
||||
const updated = await this.usersService.setAiEngineEnabled(id, dto.aiEngineEnabled);
|
||||
return { id: updated.id, username: updated.username, aiEngineEnabled: updated.aiEngineEnabled };
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Post()
|
||||
async adminCreateUser(
|
||||
|
||||
@@ -34,6 +34,7 @@ export class UsersService {
|
||||
role: true,
|
||||
isPremium: true,
|
||||
canShareRecipes: true,
|
||||
aiEngineEnabled: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { username: 'asc' },
|
||||
@@ -52,6 +53,10 @@ export class UsersService {
|
||||
return this.prisma.user.update({ where: { id }, data: { canShareRecipes } });
|
||||
}
|
||||
|
||||
setAiEngineEnabled(id: number, aiEngineEnabled: boolean) {
|
||||
return this.prisma.user.update({ where: { id }, data: { aiEngineEnabled } });
|
||||
}
|
||||
|
||||
async adminCreate(data: { username: string; email: string; password: string; role?: string }) {
|
||||
const existing = await this.prisma.user.findFirst({
|
||||
where: { OR: [{ username: data.username }, { email: data.email }] },
|
||||
|
||||
Reference in New Issue
Block a user