feat: add isPrivate field to Product model and implement private product creation and retrieval

This commit is contained in:
Nils-Johan Gynther
2026-05-01 02:29:38 +02:00
parent 1fd910b561
commit 9ee061d5f3
10 changed files with 215 additions and 18 deletions
@@ -0,0 +1,2 @@
-- Privata produkter: skapade av användare, synliga bara för ägaren
ALTER TABLE `Product` ADD COLUMN `isPrivate` BOOLEAN NOT NULL DEFAULT false;
+1
View File
@@ -52,6 +52,7 @@ model Product {
userProducts UserProduct[] userProducts UserProduct[]
categoryId Int? categoryId Int?
categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
isPrivate Boolean @default(false)
} }
model Category { model Category {
@@ -1,8 +1,12 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; import { IsInt, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
export class CreateProductDto { export class CreateProductDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MaxLength(191) @MaxLength(191)
name!: string; name!: string;
@IsOptional()
@IsInt()
categoryId?: number;
} }
@@ -94,12 +94,27 @@ export class ProductsController {
return this.productsService.findDeleted(); return this.productsService.findDeleted();
} }
// Inloggad användares egna privata produkter (måste vara före :id)
@Get('mine')
findMine(@Request() req: { user: { id: number } }) {
return this.productsService.findByOwner(req.user.id);
}
// Tillgänglig för alla inloggade användare // Tillgänglig för alla inloggade användare
@Get(':id') @Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) { findOne(@Param('id', ParseIntPipe) id: number) {
return this.productsService.findOne(id); return this.productsService.findOne(id);
} }
// Skapa en privat produkt för den inloggade användaren
@Post('private')
createPrivate(
@Body() body: CreateProductDto,
@Request() req: { user: { id: number } },
) {
return this.productsService.createPrivate(body, req.user.id);
}
@UseGuards(PremiumOrAdminGuard) @UseGuards(PremiumOrAdminGuard)
@Get(':id/suggest-category') @Get(':id/suggest-category')
@Throttle({ default: { ttl: 60_000, limit: 20 } }) @Throttle({ default: { ttl: 60_000, limit: 20 } })
+41
View File
@@ -19,6 +19,7 @@ export class ProductsService {
return this.prisma.product.findMany({ return this.prisma.product.findMany({
where: { where: {
isActive: true, isActive: true,
isPrivate: false,
...(filters?.subcategory ? { subcategory: filters.subcategory } : {}), ...(filters?.subcategory ? { subcategory: filters.subcategory } : {}),
...(filters?.tag ...(filters?.tag
? { tags: { some: { tag: { name: filters.tag } } } } ? { tags: { some: { tag: { name: filters.tag } } } }
@@ -33,6 +34,45 @@ export class ProductsService {
}); });
} }
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() { async findDuplicateCandidates() {
const products = await this.prisma.product.findMany({ const products = await this.prisma.product.findMany({
where: { where: {
@@ -107,6 +147,7 @@ export class ProductsService {
canonicalName: name, canonicalName: name,
isActive: true, isActive: true,
deletedAt: null, deletedAt: null,
...(data.categoryId != null ? { categoryId: data.categoryId } : {}),
}, },
}); });
} }
+2
View File
@@ -4,6 +4,8 @@ class AuthApiPaths {
class ProductApiPaths { class ProductApiPaths {
static const list = '/products'; static const list = '/products';
static const mine = '/products/mine';
static const createPrivate = '/products/private';
static const pending = '/products/pending'; static const pending = '/products/pending';
static const aiCategorizeBulk = '/products/ai-categorize-bulk'; static const aiCategorizeBulk = '/products/ai-categorize-bulk';
static const deleted = '/products/deleted'; static const deleted = '/products/deleted';
@@ -6,6 +6,9 @@ import '../../features/admin/domain/admin_category_node.dart';
/// ///
/// Returnerar det valda produkt-id:t, eller null om användaren avbryter. /// Returnerar det valda produkt-id:t, eller null om användaren avbryter.
/// ///
/// [onCreate] — valfri callback för att skapa ny produkt; anropas med produktnamnet
/// och returnerar `ProductOption` för den skapade produkten.
///
/// Anropas via [CategoryThenProductPicker.show]. /// Anropas via [CategoryThenProductPicker.show].
class CategoryThenProductPicker { class CategoryThenProductPicker {
CategoryThenProductPicker._(); CategoryThenProductPicker._();
@@ -35,12 +38,16 @@ class CategoryThenProductPicker {
/// ///
/// [preselectedCategoryId] — om satt scrollas trädet till den noden och den /// [preselectedCategoryId] — om satt scrollas trädet till den noden och den
/// markeras visuellt. Användaren kan fortfarande välja en annan kategori. /// markeras visuellt. Användaren kan fortfarande välja en annan kategori.
///
/// [onCreate] — valfri callback; om satt visas "Skapa ny" i produktpickern.
/// Anropas med produktnamnet och ska returnera den nya `ProductOption`.
static Future<int?> show( static Future<int?> show(
BuildContext context, { BuildContext context, {
required List<AdminCategoryNode> categoryTree, required List<AdminCategoryNode> categoryTree,
required List<ProductOption> products, required List<ProductOption> products,
int? currentProductId, int? currentProductId,
int? preselectedCategoryId, int? preselectedCategoryId,
Future<ProductOption?> Function(String name, int categoryId)? onCreate,
}) async { }) async {
// Steg 1 — välj kategori // Steg 1 — välj kategori
final selectedCategory = await showModalBottomSheet<AdminCategoryNode>( final selectedCategory = await showModalBottomSheet<AdminCategoryNode>(
@@ -64,6 +71,11 @@ class CategoryThenProductPicker {
.toList(); .toList();
final useList = filtered.isNotEmpty ? filtered : products; final useList = filtered.isNotEmpty ? filtered : products;
// Bygg eventuell onCreate-callback med categoryId inbunden
final onCreateBound = onCreate == null
? null
: (String name) => onCreate(name, selectedCategory.id);
// Steg 2 — välj produkt // Steg 2 — välj produkt
if (!context.mounted) return null; if (!context.mounted) return null;
return ProductPickerField.showSheet( return ProductPickerField.showSheet(
@@ -72,6 +84,7 @@ class CategoryThenProductPicker {
value: currentProductId, value: currentProductId,
label: 'Produkt i "${selectedCategory.name}"', label: 'Produkt i "${selectedCategory.name}"',
categoryFilter: null, // redan förfiltrerat categoryFilter: null, // redan förfiltrerat
onCreate: onCreateBound,
); );
} }
} }
+50 -5
View File
@@ -112,7 +112,8 @@ class ProductPickerField extends StatelessWidget {
/// Returnerar valt produkt-id, null (ingen ändring), eller [_clearSelectionToken] (rensa). /// Returnerar valt produkt-id, null (ingen ändring), eller [_clearSelectionToken] (rensa).
/// ///
/// [categoryFilter] — om satt visas bara produkter vars categoryId finns i listan. /// [categoryFilter] — om satt visas bara produkter vars categoryId finns i listan.
/// Används med AI-kategorisuggestion för att förifiltrera på rätt kategorigren. /// [onCreate] — valfri callback för att skapa en ny produkt; anropas med produktnamnet
/// och returnerar den nya `ProductOption` om skapandet lyckades.
static Future<int?> showSheet( static Future<int?> showSheet(
BuildContext context, { BuildContext context, {
required List<ProductOption> products, required List<ProductOption> products,
@@ -120,6 +121,7 @@ class ProductPickerField extends StatelessWidget {
String label = 'Produkt', String label = 'Produkt',
String? initialQuery, String? initialQuery,
Set<int>? categoryFilter, Set<int>? categoryFilter,
Future<ProductOption?> Function(String name)? onCreate,
}) async { }) async {
// Filtrera på kategori om angiven, men visa alla om filtret ger nollresultat // Filtrera på kategori om angiven, men visa alla om filtret ger nollresultat
final baseList = categoryFilter != null && categoryFilter.isNotEmpty final baseList = categoryFilter != null && categoryFilter.isNotEmpty
@@ -134,17 +136,54 @@ class ProductPickerField extends StatelessWidget {
builder: (sheetContext) { builder: (sheetContext) {
final controller = TextEditingController(text: initialQuery ?? ''); final controller = TextEditingController(text: initialQuery ?? '');
var query = initialQuery ?? ''; var query = initialQuery ?? '';
// Mutable lokal kopia — nya produkter kan läggas till
var displayList = useList.toList();
return StatefulBuilder( return StatefulBuilder(
builder: (ctx, setModalState) { builder: (ctx, setModalState) {
final normalizedQuery = query.trim().toLowerCase(); final normalizedQuery = query.trim().toLowerCase();
final filtered = normalizedQuery.isEmpty final filtered = normalizedQuery.isEmpty
? useList ? displayList
: useList : displayList
.where((p) => p.name.toLowerCase().contains(normalizedQuery)) .where((p) => p.name.toLowerCase().contains(normalizedQuery))
.toList(); .toList();
final isFiltered = useList.length < products.length; final isFiltered = displayList.length < products.length;
// "Skapa ny produkt"-dialog
Future<void> openCreateDialog() async {
final nameCtrl = TextEditingController(text: query.trim());
final confirmed = await showDialog<bool>(
context: ctx,
builder: (dCtx) => AlertDialog(
title: const Text('Ny produkt'),
content: TextField(
controller: nameCtrl,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Produktnamn',
border: OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dCtx, false),
child: const Text('Avbryt'),
),
FilledButton(
onPressed: () => Navigator.pop(dCtx, true),
child: const Text('Skapa'),
),
],
),
);
if (confirmed != true || !ctx.mounted) return;
final newProduct = await onCreate!(nameCtrl.text);
if (newProduct == null || !ctx.mounted) return;
setModalState(() => displayList = [...displayList, newProduct]);
if (ctx.mounted) Navigator.pop(ctx, newProduct.id);
}
return SizedBox( return SizedBox(
height: MediaQuery.of(ctx).size.height * 0.85, height: MediaQuery.of(ctx).size.height * 0.85,
@@ -161,12 +200,18 @@ class ProductPickerField extends StatelessWidget {
Text(label, style: Theme.of(ctx).textTheme.titleMedium), Text(label, style: Theme.of(ctx).textTheme.titleMedium),
if (isFiltered) if (isFiltered)
Text( Text(
'Visar ${useList.length} produkter i föreslagen kategori', 'Visar ${displayList.length} produkter i föreslagen kategori',
style: Theme.of(ctx).textTheme.labelSmall?.copyWith(color: Colors.green.shade700), style: Theme.of(ctx).textTheme.labelSmall?.copyWith(color: Colors.green.shade700),
), ),
], ],
), ),
), ),
if (onCreate != null)
TextButton.icon(
onPressed: openCreateDialog,
icon: const Icon(Icons.add),
label: const Text('Skapa ny'),
),
TextButton.icon( TextButton.icon(
onPressed: () => Navigator.pop(ctx, _clearSelectionToken), onPressed: () => Navigator.pop(ctx, _clearSelectionToken),
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear),
@@ -142,6 +142,23 @@ class AdminRepository {
.toList(); .toList();
} }
/// Skapar en ny aktiv produkt (kräver admin). Returnerar `{id, name, categoryId?}`.
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) async {
final token = await _token();
final data = await guardedApiCall(
_ref,
() => _apiClient.postJson(
ProductApiPaths.list,
body: {
'name': name.trim(),
if (categoryId != null) 'categoryId': categoryId,
},
token: token,
),
);
return data as Map<String, dynamic>;
}
Future<List<AdminProduct>> listDeletedProducts() async { Future<List<AdminProduct>> listDeletedProducts() async {
final token = await _token(); final token = await _token();
final data = await guardedApiCall( final data = await guardedApiCall(
@@ -44,12 +44,14 @@ class _EditDialog extends StatefulWidget {
final _ItemEdit current; final _ItemEdit current;
final List<ProductOption> products; final List<ProductOption> products;
final List<AdminCategoryNode> categoryTree; final List<AdminCategoryNode> categoryTree;
final Future<ProductOption?> Function(String name, int categoryId)? onCreate;
const _EditDialog({ const _EditDialog({
required this.item, required this.item,
required this.current, required this.current,
required this.products, required this.products,
required this.categoryTree, required this.categoryTree,
this.onCreate,
}); });
@override @override
@@ -62,6 +64,8 @@ class _EditDialogState extends State<_EditDialog> {
int? _productId; int? _productId;
String? _productName; String? _productName;
_Destination _destination = _Destination.inventory; _Destination _destination = _Destination.inventory;
// Lokal lista — utökas om nya produkter skapas under dialogen
late List<ProductOption> _localProducts;
@override @override
void initState() { void initState() {
@@ -69,6 +73,7 @@ class _EditDialogState extends State<_EditDialog> {
_productId = widget.current.productId; _productId = widget.current.productId;
_productName = widget.current.productName; _productName = widget.current.productName;
_destination = widget.current.destination; _destination = widget.current.destination;
_localProducts = List.of(widget.products);
_quantityCtrl = TextEditingController( _quantityCtrl = TextEditingController(
text: (widget.current.quantity ?? widget.item.quantity)?.toString() ?? '', text: (widget.current.quantity ?? widget.item.quantity)?.toString() ?? '',
); );
@@ -95,17 +100,30 @@ class _EditDialogState extends State<_EditDialog> {
// Hjälpfunktion: välj produkt via tvåstegs-picker (kategori → produkt) // Hjälpfunktion: välj produkt via tvåstegs-picker (kategori → produkt)
Future<void> openCategoryPicker({int? preselectedCategoryId}) async { Future<void> openCategoryPicker({int? preselectedCategoryId}) async {
// onCreate-wrapper: lägg även till den nya produkten i _localProducts
Future<ProductOption?> Function(String, int)? onCreateWrapped;
if (widget.onCreate != null) {
onCreateWrapped = (name, categoryId) async {
final newProduct = await widget.onCreate!(name, categoryId);
if (newProduct != null && mounted) {
setState(() => _localProducts = [..._localProducts, newProduct]);
}
return newProduct;
};
}
final id = await CategoryThenProductPicker.show( final id = await CategoryThenProductPicker.show(
context, context,
categoryTree: widget.categoryTree, categoryTree: widget.categoryTree,
products: widget.products, products: _localProducts,
currentProductId: _productId, currentProductId: _productId,
preselectedCategoryId: preselectedCategoryId, preselectedCategoryId: preselectedCategoryId,
onCreate: onCreateWrapped,
); );
if (id != null && mounted) { if (id != null && mounted) {
setState(() { setState(() {
_productId = id; _productId = id;
_productName = widget.products _productName = _localProducts
.cast<ProductOption?>() .cast<ProductOption?>()
.firstWhere((p) => p?.id == id, orElse: () => null) .firstWhere((p) => p?.id == id, orElse: () => null)
?.name; ?.name;
@@ -178,7 +196,7 @@ class _EditDialogState extends State<_EditDialog> {
children: [ children: [
Expanded( Expanded(
child: ProductPickerField( child: ProductPickerField(
products: widget.products, products: _localProducts,
value: _productId, value: _productId,
label: 'Produkt', label: 'Produkt',
onChanged: (id) { onChanged: (id) {
@@ -186,7 +204,7 @@ class _EditDialogState extends State<_EditDialog> {
_productId = id; _productId = id;
_productName = id == null _productName = id == null
? null ? null
: widget.products : _localProducts
.cast<ProductOption?>() .cast<ProductOption?>()
.firstWhere((p) => p?.id == id, orElse: () => null) .firstWhere((p) => p?.id == id, orElse: () => null)
?.name; ?.name;
@@ -303,17 +321,28 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
final adminRepo = ref.read(adminRepositoryProvider); final adminRepo = ref.read(adminRepositoryProvider);
final results = await Future.wait([ final results = await Future.wait([
api.getJson(ProductApiPaths.list, token: token), api.getJson(ProductApiPaths.list, token: token),
api.getJson(ProductApiPaths.mine, token: token),
adminRepo.listCategoryTree(), adminRepo.listCategoryTree(),
]); ]);
final data = results[0]; final globalData = results[0];
final list = data is List ? data : ((data as Map<String, dynamic>?)?['items'] as List? ?? []); final mineData = results[1];
final globalList = globalData is List
? globalData
: ((globalData as Map<String, dynamic>?)?['items'] as List? ?? []);
final mineList = mineData is List
? mineData
: ((mineData as Map<String, dynamic>?)?['items'] as List? ?? []);
if (mounted) { if (mounted) {
setState(() { setState(() {
_products = list _products = [
...globalList
.cast<Map<String, dynamic>>() .cast<Map<String, dynamic>>()
.map((e) => (id: e['id'] as int, name: e['name'] as String, categoryId: (e['categoryId'] as num?)?.toInt())) .map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
.toList(); ...mineList
_categoryTree = results[1] as List<AdminCategoryNode>; .cast<Map<String, dynamic>>()
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
];
_categoryTree = results[2] as List<AdminCategoryNode>;
_loadingProducts = false; _loadingProducts = false;
}); });
} }
@@ -413,7 +442,35 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
final result = await showDialog<_ItemEdit>( final result = await showDialog<_ItemEdit>(
context: context, context: context,
builder: (_) => _EditDialog(item: item, current: current, products: _products, categoryTree: _categoryTree), builder: (_) => _EditDialog(
item: item,
current: current,
products: _products,
categoryTree: _categoryTree,
onCreate: (name, categoryId) async {
try {
final token = await ref.read(authStateProvider.future);
final api = ref.read(apiClientProvider);
final data = await api.postJson(
ProductApiPaths.createPrivate,
body: {
'name': name.trim(),
'categoryId': categoryId,
},
token: token,
) as Map<String, dynamic>;
final newProduct = (
id: data['id'] as int,
name: (data['canonicalName'] ?? data['name']) as String,
categoryId: categoryId,
);
if (mounted) setState(() => _products = [..._products, newProduct]);
return newProduct;
} catch (_) {
return null;
}
},
),
); );
if (result != null && mounted) { if (result != null && mounted) {
setState(() { setState(() {