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[]
categoryId Int?
categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
isPrivate Boolean @default(false)
}
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 {
@IsString()
@IsNotEmpty()
@MaxLength(191)
name!: string;
@IsOptional()
@IsInt()
categoryId?: number;
}
@@ -94,12 +94,27 @@ export class ProductsController {
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
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
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)
@Get(':id/suggest-category')
@Throttle({ default: { ttl: 60_000, limit: 20 } })
+41
View File
@@ -19,6 +19,7 @@ export class ProductsService {
return this.prisma.product.findMany({
where: {
isActive: true,
isPrivate: false,
...(filters?.subcategory ? { subcategory: filters.subcategory } : {}),
...(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() {
const products = await this.prisma.product.findMany({
where: {
@@ -107,6 +147,7 @@ export class ProductsService {
canonicalName: name,
isActive: true,
deletedAt: null,
...(data.categoryId != null ? { categoryId: data.categoryId } : {}),
},
});
}
+2
View File
@@ -4,6 +4,8 @@ class AuthApiPaths {
class ProductApiPaths {
static const list = '/products';
static const mine = '/products/mine';
static const createPrivate = '/products/private';
static const pending = '/products/pending';
static const aiCategorizeBulk = '/products/ai-categorize-bulk';
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.
///
/// [onCreate] — valfri callback för att skapa ny produkt; anropas med produktnamnet
/// och returnerar `ProductOption` för den skapade produkten.
///
/// Anropas via [CategoryThenProductPicker.show].
class CategoryThenProductPicker {
CategoryThenProductPicker._();
@@ -35,12 +38,16 @@ class CategoryThenProductPicker {
///
/// [preselectedCategoryId] — om satt scrollas trädet till den noden och den
/// 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(
BuildContext context, {
required List<AdminCategoryNode> categoryTree,
required List<ProductOption> products,
int? currentProductId,
int? preselectedCategoryId,
Future<ProductOption?> Function(String name, int categoryId)? onCreate,
}) async {
// Steg 1 — välj kategori
final selectedCategory = await showModalBottomSheet<AdminCategoryNode>(
@@ -64,6 +71,11 @@ class CategoryThenProductPicker {
.toList();
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
if (!context.mounted) return null;
return ProductPickerField.showSheet(
@@ -72,6 +84,7 @@ class CategoryThenProductPicker {
value: currentProductId,
label: 'Produkt i "${selectedCategory.name}"',
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).
///
/// [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(
BuildContext context, {
required List<ProductOption> products,
@@ -120,6 +121,7 @@ class ProductPickerField extends StatelessWidget {
String label = 'Produkt',
String? initialQuery,
Set<int>? categoryFilter,
Future<ProductOption?> Function(String name)? onCreate,
}) async {
// Filtrera på kategori om angiven, men visa alla om filtret ger nollresultat
final baseList = categoryFilter != null && categoryFilter.isNotEmpty
@@ -134,17 +136,54 @@ class ProductPickerField extends StatelessWidget {
builder: (sheetContext) {
final controller = TextEditingController(text: initialQuery ?? '');
var query = initialQuery ?? '';
// Mutable lokal kopia — nya produkter kan läggas till
var displayList = useList.toList();
return StatefulBuilder(
builder: (ctx, setModalState) {
final normalizedQuery = query.trim().toLowerCase();
final filtered = normalizedQuery.isEmpty
? useList
: useList
? displayList
: displayList
.where((p) => p.name.toLowerCase().contains(normalizedQuery))
.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(
height: MediaQuery.of(ctx).size.height * 0.85,
@@ -161,12 +200,18 @@ class ProductPickerField extends StatelessWidget {
Text(label, style: Theme.of(ctx).textTheme.titleMedium),
if (isFiltered)
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),
),
],
),
),
if (onCreate != null)
TextButton.icon(
onPressed: openCreateDialog,
icon: const Icon(Icons.add),
label: const Text('Skapa ny'),
),
TextButton.icon(
onPressed: () => Navigator.pop(ctx, _clearSelectionToken),
icon: const Icon(Icons.clear),
@@ -142,6 +142,23 @@ class AdminRepository {
.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 {
final token = await _token();
final data = await guardedApiCall(
@@ -44,12 +44,14 @@ class _EditDialog extends StatefulWidget {
final _ItemEdit current;
final List<ProductOption> products;
final List<AdminCategoryNode> categoryTree;
final Future<ProductOption?> Function(String name, int categoryId)? onCreate;
const _EditDialog({
required this.item,
required this.current,
required this.products,
required this.categoryTree,
this.onCreate,
});
@override
@@ -62,6 +64,8 @@ class _EditDialogState extends State<_EditDialog> {
int? _productId;
String? _productName;
_Destination _destination = _Destination.inventory;
// Lokal lista — utökas om nya produkter skapas under dialogen
late List<ProductOption> _localProducts;
@override
void initState() {
@@ -69,6 +73,7 @@ class _EditDialogState extends State<_EditDialog> {
_productId = widget.current.productId;
_productName = widget.current.productName;
_destination = widget.current.destination;
_localProducts = List.of(widget.products);
_quantityCtrl = TextEditingController(
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)
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(
context,
categoryTree: widget.categoryTree,
products: widget.products,
products: _localProducts,
currentProductId: _productId,
preselectedCategoryId: preselectedCategoryId,
onCreate: onCreateWrapped,
);
if (id != null && mounted) {
setState(() {
_productId = id;
_productName = widget.products
_productName = _localProducts
.cast<ProductOption?>()
.firstWhere((p) => p?.id == id, orElse: () => null)
?.name;
@@ -178,7 +196,7 @@ class _EditDialogState extends State<_EditDialog> {
children: [
Expanded(
child: ProductPickerField(
products: widget.products,
products: _localProducts,
value: _productId,
label: 'Produkt',
onChanged: (id) {
@@ -186,7 +204,7 @@ class _EditDialogState extends State<_EditDialog> {
_productId = id;
_productName = id == null
? null
: widget.products
: _localProducts
.cast<ProductOption?>()
.firstWhere((p) => p?.id == id, orElse: () => null)
?.name;
@@ -303,17 +321,28 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
final adminRepo = ref.read(adminRepositoryProvider);
final results = await Future.wait([
api.getJson(ProductApiPaths.list, token: token),
api.getJson(ProductApiPaths.mine, token: token),
adminRepo.listCategoryTree(),
]);
final data = results[0];
final list = data is List ? data : ((data as Map<String, dynamic>?)?['items'] as List? ?? []);
final globalData = results[0];
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) {
setState(() {
_products = list
_products = [
...globalList
.cast<Map<String, dynamic>>()
.map((e) => (id: e['id'] as int, name: e['name'] as String, categoryId: (e['categoryId'] as num?)?.toInt()))
.toList();
_categoryTree = results[1] as List<AdminCategoryNode>;
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
...mineList
.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;
});
}
@@ -413,7 +442,35 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
final result = await showDialog<_ItemEdit>(
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) {
setState(() {