Files
recipe-app/backend/src/products/products.service.ts
T

542 lines
15 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { normalizeName } from '../common/utils/normalize-name';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';
import { UpsertNutritionDto } from './dto/upsert-nutrition.dto';
import { AiService } from '../ai/ai.service';
import { CategoriesService } from '../categories/categories.service';
@Injectable()
export class ProductsService {
constructor(
private readonly prisma: PrismaService,
private readonly aiService: AiService,
private readonly categoriesService: CategoriesService,
) {}
async findAll(filters?: { tag?: string; subcategory?: string }) {
return this.prisma.product.findMany({
where: {
isActive: true,
isPrivate: false,
...(filters?.subcategory ? { subcategory: filters.subcategory } : {}),
...(filters?.tag
? { tags: { some: { tag: { name: filters.tag } } } }
: {}),
},
include: {
tags: { include: { tag: true } },
nutrition: true,
categoryRef: { include: { parent: { include: { parent: true } } } },
},
orderBy: { name: 'asc' },
});
}
async findByOwner(userId: number) {
return this.prisma.product.findMany({
where: { ownerId: userId, isPrivate: true, isActive: true },
select: { id: true, name: true, canonicalName: true, categoryId: true },
orderBy: { name: 'asc' },
});
}
async createPrivate(data: CreateProductDto, userId: number) {
const name = data.name.trim();
// Privata produkters normalizedName är prefixade för att undvika kollision
const normalizedName = `private:${userId}:${normalizeName(name)}`;
const existing = await this.prisma.product.findUnique({
where: { normalizedName },
});
if (existing && existing.isActive) return existing;
if (existing) {
return this.prisma.product.update({
where: { id: existing.id },
data: { isActive: true, deletedAt: null, name, canonicalName: name },
});
}
return this.prisma.product.create({
data: {
name,
normalizedName,
canonicalName: name,
isActive: true,
isPrivate: true,
ownerId: userId,
...(data.categoryId != null ? { categoryId: data.categoryId } : {}),
},
});
}
async findDuplicateCandidates() {
const products = await this.prisma.product.findMany({
where: {
isActive: true,
},
orderBy: {
name: 'asc',
},
});
const grouped = new Map<string, typeof products>();
for (const product of products) {
const key = product.normalizedName;
if (!grouped.has(key)) {
grouped.set(key, []);
}
grouped.get(key)!.push(product);
}
return Array.from(grouped.entries())
.filter(([, items]) => items.length > 1)
.map(([normalizedName, items]) => ({
normalizedName,
count: items.length,
products: items,
}));
}
async findOne(id: number) {
const product = await this.prisma.product.findUnique({
where: { id },
});
if (!product) {
throw new NotFoundException(`Product with id ${id} not found`);
}
return product;
}
async create(data: CreateProductDto, ownerId?: number) {
const name = data.name.trim();
const normalizedName = normalizeName(name);
const existing = await this.prisma.product.findUnique({
where: { normalizedName },
});
if (existing) {
if (!existing.isActive) {
return this.prisma.product.update({
where: { id: existing.id },
data: {
isActive: true,
deletedAt: null,
name,
canonicalName: name,
},
});
}
return existing;
}
if (!ownerId) {
throw new Error('ownerId är obligatorisk för att skapa en produkt');
}
return this.prisma.product.create({
data: {
ownerId,
name,
normalizedName,
canonicalName: name,
isActive: true,
deletedAt: null,
...(data.categoryId != null ? { categoryId: data.categoryId } : {}),
},
});
}
async update(id: number, data: UpdateProductDto) {
await this.findOne(id);
const updateData: {
name?: string;
normalizedName?: string;
canonicalName?: string;
category?: string | null;
subcategory?: string | null;
brand?: string | null;
categoryId?: number | null;
} = {};
if (typeof data.name === 'string') {
const name = data.name.trim();
const normalizedName = normalizeName(name);
const existing = await this.prisma.product.findUnique({
where: { normalizedName },
});
if (existing && existing.id !== id) {
// Om en annan produkt har samma namn, returnera ett tydligt fel
throw new Error('Det finns redan en annan produkt med detta namn. Välj ett unikt namn.');
}
updateData.name = name;
updateData.normalizedName = normalizedName;
}
if (typeof data.canonicalName === 'string') {
updateData.canonicalName = data.canonicalName.trim() || undefined;
}
if (typeof data.category === 'string') {
updateData.category = data.category.trim() || null;
}
if (typeof data.subcategory === 'string') {
updateData.subcategory = data.subcategory.trim() || null;
}
if (typeof data.brand === 'string') {
updateData.brand = data.brand.trim() || null;
}
if ('categoryId' in data) {
updateData.categoryId = data.categoryId ?? null;
}
return this.prisma.product.update({
where: { id },
data: updateData,
include: { tags: { include: { tag: true } }, nutrition: true },
});
}
async updateCanonicalName(id: number, canonicalName: string) {
await this.findOne(id);
const cleaned = canonicalName.trim();
return this.prisma.product.update({
where: { id },
data: {
canonicalName: cleaned,
},
});
}
async findDeleted() {
return this.prisma.product.findMany({
where: { isActive: false },
orderBy: { deletedAt: 'desc' },
});
}
async remove(id: number) {
await this.findOne(id);
return this.prisma.product.update({
where: { id },
data: {
isActive: false,
deletedAt: new Date(),
},
});
}
async permanentDelete(id: number) {
const product = await this.prisma.product.findUnique({ where: { id } });
if (!product) {
throw new NotFoundException(`Product with id ${id} not found`);
}
// Ta bort beroenden först
await this.prisma.productTag.deleteMany({ where: { productId: id } });
await this.prisma.userProduct.deleteMany({ where: { productId: id } });
return this.prisma.product.delete({ where: { id } });
}
async restore(id: number) {
const product = await this.findOne(id);
if (product.isActive) {
return product;
}
return this.prisma.product.update({
where: { id },
data: {
isActive: true,
deletedAt: null,
},
});
}
async merge(sourceProductId: number, targetProductId: number) {
if (sourceProductId === targetProductId) {
throw new Error('sourceProductId och targetProductId kan inte vara samma');
}
const source = await this.prisma.product.findUnique({
where: { id: sourceProductId },
});
if (!source) {
throw new NotFoundException(
`Source product with id ${sourceProductId} not found`,
);
}
const target = await this.prisma.product.findUnique({
where: { id: targetProductId },
});
if (!target) {
throw new NotFoundException(
`Target product with id ${targetProductId} not found`,
);
}
return this.prisma.$transaction(async (tx) => {
const movedInventoryCount = await tx.inventoryItem.updateMany({
where: { productId: sourceProductId },
data: { productId: targetProductId },
});
const softDeletedSource = await tx.product.update({
where: { id: sourceProductId },
data: {
isActive: false,
deletedAt: new Date(),
},
});
return {
message: 'Products merged successfully',
sourceProductId,
targetProductId,
movedInventoryCount: movedInventoryCount.count,
softDeletedSource,
};
});
}
async previewMerge(sourceProductId: number, targetProductId: number) {
if (sourceProductId === targetProductId) {
throw new Error('sourceProductId och targetProductId kan inte vara samma');
}
const [source, target, sourceInventoryCount, targetInventoryCount] =
await Promise.all([
this.prisma.product.findUnique({
where: { id: sourceProductId },
}),
this.prisma.product.findUnique({
where: { id: targetProductId },
}),
this.prisma.inventoryItem.count({
where: { productId: sourceProductId },
}),
this.prisma.inventoryItem.count({
where: { productId: targetProductId },
}),
]);
if (!source) {
throw new NotFoundException(
`Source product with id ${sourceProductId} not found`,
);
}
if (!target) {
throw new NotFoundException(
`Target product with id ${targetProductId} not found`,
);
}
return {
source: {
...source,
inventoryCount: sourceInventoryCount,
},
target: {
...target,
inventoryCount: targetInventoryCount,
},
outcome: {
inventoryItemsToMove: sourceInventoryCount,
sourceWillBeSoftDeleted: true,
targetWillRemainActive: true,
},
};
}
async backfillCanonicalNames() {
const products = await this.prisma.product.findMany({
where: {
canonicalName: null,
},
});
const results = await this.prisma.$transaction(
products.map((product) =>
this.prisma.product.update({
where: { id: product.id },
data: {
canonicalName: product.name,
},
}),
),
);
return {
message: 'Canonical names backfilled successfully',
updatedCount: results.length,
products: results,
};
}
async setTags(productId: number, tagNames: string[]) {
await this.findOne(productId);
// Skapa taggar som inte finns och hämta ID för alla
const tags = await this.prisma.$transaction(
tagNames.map((name) =>
this.prisma.tag.upsert({
where: { name },
create: { name },
update: {},
}),
),
);
// Ersätt alla taggkopplingar för produkten
await this.prisma.productTag.deleteMany({ where: { productId } });
if (tags.length > 0) {
await this.prisma.productTag.createMany({
data: tags.map((tag) => ({ productId, tagId: tag.id })),
skipDuplicates: true,
});
}
return this.prisma.product.findUnique({
where: { id: productId },
include: { tags: { include: { tag: true } }, nutrition: true },
});
}
async upsertNutrition(productId: number, data: UpsertNutritionDto) {
await this.findOne(productId);
return this.prisma.nutrition.upsert({
where: { productId },
create: { productId, ...data },
update: { ...data },
});
}
async findAllTags() {
return this.prisma.tag.findMany({ orderBy: { name: 'asc' } });
}
async resetAll() {
await this.prisma.$transaction([
this.prisma.receiptAlias.deleteMany(),
this.prisma.inventoryConsumption.deleteMany(),
this.prisma.inventoryItem.deleteMany(),
this.prisma.pantryItem.deleteMany(),
this.prisma.nutrition.deleteMany(),
this.prisma.productTag.deleteMany(),
this.prisma.tag.deleteMany(),
this.prisma.userProduct.deleteMany(),
this.prisma.recipeIngredient.deleteMany(),
this.prisma.product.deleteMany(),
]);
return { ok: true };
}
async bulkUpdate(ids: number[], data: { categoryId?: number | null }) {
const updateData: Record<string, any> = {};
if ('categoryId' in data) {
updateData.categoryId = data.categoryId;
}
if (Object.keys(updateData).length === 0) return { updated: 0 };
await this.prisma.product.updateMany({ where: { id: { in: ids } }, data: updateData });
return { updated: ids.length };
}
async findUncategorized(): Promise<{ id: number; name: string; canonicalName: string | null }[]> {
return this.prisma.product.findMany({
where: { isActive: true, categoryId: null, status: 'active' },
select: { id: true, name: true, canonicalName: true },
orderBy: { name: 'asc' },
});
}
async aiCategorizeBulk(productIds?: number[]): Promise<{ productId: number; productName: string; suggestion: object }[]> {
const categories = await this.categoriesService.findFlattened();
let products: { id: number; name: string }[];
if (productIds && productIds.length > 0) {
const found = await Promise.all(productIds.map((id) => this.findOne(id)));
products = found.map((p) => ({ id: p.id, name: p.canonicalName ?? p.name }));
} else {
products = await this.findUncategorized();
}
const results: { productId: number; productName: string; suggestion: object }[] = [];
for (const product of products) {
const suggestion = await this.aiService.suggestCategory(product.name, categories);
results.push({ productId: product.id, productName: product.name, suggestion });
}
return results;
}
async findPending() {
return this.prisma.product.findMany({
where: { status: 'pending' },
include: {
categoryRef: { include: { parent: true } },
owner: { select: { id: true, username: true } },
},
orderBy: { createdAt: 'desc' },
});
}
async createPending(data: CreateProductDto, userId: number) {
const name = data.name.trim();
const normalizedName = normalizeName(name);
const existing = await this.prisma.product.findUnique({
where: { normalizedName },
});
if (existing) {
// Om produkten redan finns (aktiv), returnera den direkt
if (existing.isActive && existing.status === 'active') {
return existing;
}
// Om det redan finns ett pending-förslag, returnera det
if (existing.status === 'pending') {
return existing;
}
}
return this.prisma.product.create({
data: {
name,
normalizedName,
canonicalName: name,
isActive: false,
status: 'pending',
ownerId: userId,
},
});
}
setStatus(id: number, status: string) {
return this.prisma.product.update({ where: { id }, data: { status } });
}
}