diff --git a/backend/src/receipt-alias/dto/update-receipt-alias.dto.ts b/backend/src/receipt-alias/dto/update-receipt-alias.dto.ts new file mode 100644 index 00000000..8aed9e39 --- /dev/null +++ b/backend/src/receipt-alias/dto/update-receipt-alias.dto.ts @@ -0,0 +1,12 @@ +import { IsInt, IsOptional, IsString, MinLength } from 'class-validator'; + +export class UpdateReceiptAliasDto { + @IsOptional() + @IsString() + @MinLength(1) + receiptName?: string; + + @IsOptional() + @IsInt() + productId?: number; +} diff --git a/backend/src/receipt-alias/receipt-alias.controller.ts b/backend/src/receipt-alias/receipt-alias.controller.ts index 6dde435b..115766cc 100644 --- a/backend/src/receipt-alias/receipt-alias.controller.ts +++ b/backend/src/receipt-alias/receipt-alias.controller.ts @@ -1,6 +1,16 @@ -import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Param, + ParseIntPipe, + Patch, + Post, +} from '@nestjs/common'; import { ReceiptAliasService } from './receipt-alias.service'; import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto'; +import { UpdateReceiptAliasDto } from './dto/update-receipt-alias.dto'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; @Controller('receipt-aliases') @@ -20,6 +30,15 @@ export class ReceiptAliasController { return this.receiptAliasService.upsert(dto, user.userId, user.role); } + @Patch(':id') + update( + @Param('id', ParseIntPipe) id: number, + @Body() dto: UpdateReceiptAliasDto, + @CurrentUser() user: { userId: number; role: string }, + ) { + return this.receiptAliasService.update(id, dto, user.userId, user.role); + } + @Delete(':id') remove( @Param('id', ParseIntPipe) id: number, diff --git a/backend/src/receipt-alias/receipt-alias.security.spec.ts b/backend/src/receipt-alias/receipt-alias.security.spec.ts index d6ebd281..4ecd0f15 100644 --- a/backend/src/receipt-alias/receipt-alias.security.spec.ts +++ b/backend/src/receipt-alias/receipt-alias.security.spec.ts @@ -4,6 +4,7 @@ describe('ReceiptAlias controller security', () => { const receiptAliasServiceMock = { findAllForUser: jest.fn(), upsert: jest.fn(), + update: jest.fn(), remove: jest.fn(), }; @@ -37,4 +38,13 @@ describe('ReceiptAlias controller security', () => { expect(receiptAliasServiceMock.remove).toHaveBeenCalledWith(10, 42, 'user'); }); + + it('update scopear till @CurrentUser userId + role', () => { + const dto = { receiptName: 'Arla mjolk 1l', productId: 7 }; + receiptAliasServiceMock.update.mockResolvedValue({ id: 10 }); + + controller.update(10, dto as any, { userId: 42, role: 'user' }); + + expect(receiptAliasServiceMock.update).toHaveBeenCalledWith(10, dto, 42, 'user'); + }); }); diff --git a/backend/src/receipt-alias/receipt-alias.service.spec.ts b/backend/src/receipt-alias/receipt-alias.service.spec.ts index e115b2eb..48a60a59 100644 --- a/backend/src/receipt-alias/receipt-alias.service.spec.ts +++ b/backend/src/receipt-alias/receipt-alias.service.spec.ts @@ -64,4 +64,65 @@ describe('ReceiptAliasService', () => { await expect(service.remove(99, 10, 'admin')).rejects.toBeInstanceOf(NotFoundException); }); + + it('uppdaterar alias säkert via update()', async () => { + prismaMock.receiptAlias.findUnique.mockResolvedValue({ + id: 12, + receiptName: 'mjolk 1l', + productId: 7, + ownerId: 10, + isGlobal: false, + }); + prismaMock.receiptAlias.findFirst.mockResolvedValue(null); + prismaMock.receiptAlias.update.mockResolvedValue({ id: 12 }); + + await service.update( + 12, + { receiptName: ' ARLA MJOLK 1L ', productId: 8 }, + 10, + 'user', + ); + + expect(prismaMock.receiptAlias.update).toHaveBeenCalledWith({ + where: { id: 12 }, + data: { + receiptName: 'arla mjolk 1l', + productId: 8, + }, + }); + }); + + it('blockerar update när aliasnamn krockar i samma scope', async () => { + prismaMock.receiptAlias.findUnique.mockResolvedValue({ + id: 12, + receiptName: 'mjolk 1l', + productId: 7, + ownerId: 10, + isGlobal: false, + }); + prismaMock.receiptAlias.findFirst.mockResolvedValue({ + id: 99, + receiptName: 'arla mjolk 1l', + ownerId: 10, + isGlobal: false, + }); + + await expect( + service.update(12, { receiptName: 'ARLA MJOLK 1L' }, 10, 'user'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('blockerar update för obehörig användare', async () => { + prismaMock.receiptAlias.findUnique.mockResolvedValue({ + id: 12, + receiptName: 'mjolk 1l', + productId: 7, + ownerId: null, + isGlobal: true, + }); + + await expect( + service.update(12, { receiptName: 'mjolk 1 liter' }, 10, 'user'), + ).rejects.toBeInstanceOf(ForbiddenException); + }); }); \ No newline at end of file diff --git a/backend/src/receipt-alias/receipt-alias.service.ts b/backend/src/receipt-alias/receipt-alias.service.ts index 0c76af7c..81ed28de 100644 --- a/backend/src/receipt-alias/receipt-alias.service.ts +++ b/backend/src/receipt-alias/receipt-alias.service.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto'; +import { UpdateReceiptAliasDto } from './dto/update-receipt-alias.dto'; import { normalizeReceiptAliasName, validateReceiptAliasName, @@ -93,4 +94,60 @@ export class ReceiptAliasService { return this.prisma.receiptAlias.delete({ where: { id } }); } + + async update( + id: number, + dto: UpdateReceiptAliasDto, + userId: number, + role: string, + ) { + const alias = await this.prisma.receiptAlias.findUnique({ where: { id } }); + if (!alias) { + throw new NotFoundException(`Aliaspost med id ${id} hittades inte`); + } + + const canEdit = + role === 'admin' || + (alias.ownerId === userId && alias.isGlobal === false); + + if (!canEdit) { + throw new ForbiddenException('Du har inte behörighet att redigera aliaset'); + } + + if (dto.receiptName == null && dto.productId == null) { + throw new BadRequestException('Inget att uppdatera. Ange receiptName eller productId.'); + } + + let nextReceiptName = alias.receiptName; + if (dto.receiptName != null) { + nextReceiptName = normalizeReceiptAliasName(dto.receiptName); + const validationError = validateReceiptAliasName(nextReceiptName); + if (validationError) { + throw new BadRequestException(validationError); + } + } + + const nextProductId = dto.productId ?? alias.productId; + + // Skydda mot krock i samma scope när receiptName ändras. + if (nextReceiptName !== alias.receiptName) { + const conflict = await this.prisma.receiptAlias.findFirst({ + where: alias.isGlobal + ? { receiptName: nextReceiptName, isGlobal: true } + : { receiptName: nextReceiptName, ownerId: alias.ownerId, isGlobal: false }, + }); + + if (conflict && conflict.id !== alias.id) { + throw new BadRequestException('Aliasnamnet finns redan i samma scope.'); + } + } + + return this.prisma.receiptAlias.update({ + where: { id: alias.id }, + data: { + receiptName: nextReceiptName, + productId: nextProductId, + }, + }); + } } diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index 658ac7de..443f387a 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -40,6 +40,7 @@ class ReceiptImportApiPaths { class ReceiptAliasApiPaths { static const list = '/receipt-aliases'; + static String update(int id) => '/receipt-aliases/$id'; static String remove(int id) => '/receipt-aliases/$id'; } diff --git a/flutter/lib/features/admin/data/admin_repository.dart b/flutter/lib/features/admin/data/admin_repository.dart index b1938e91..67d0c958 100644 --- a/flutter/lib/features/admin/data/admin_repository.dart +++ b/flutter/lib/features/admin/data/admin_repository.dart @@ -420,6 +420,19 @@ class AdminRepository { 'isGlobal': isGlobal, }); + Future updateReceiptAlias( + int id, { + String? receiptName, + int? productId, + }) { + final body = { + if (receiptName != null) 'receiptName': receiptName, + if (productId != null) 'productId': productId, + }; + + return _patchVoid(ReceiptAliasApiPaths.update(id), body); + } + Future removeReceiptAlias(int id) => _deleteVoid(ReceiptAliasApiPaths.remove(id)); diff --git a/flutter/lib/features/admin/presentation/admin_aliases_panel.dart b/flutter/lib/features/admin/presentation/admin_aliases_panel.dart index 38ddddad..c7a7dfad 100644 --- a/flutter/lib/features/admin/presentation/admin_aliases_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_aliases_panel.dart @@ -26,6 +26,7 @@ class _AdminAliasesPanelState extends ConsumerState { final TextEditingController _aliasController = TextEditingController(); int? _selectedProductId; + int? _editingAliasId; @override void initState() { @@ -79,18 +80,32 @@ class _AdminAliasesPanelState extends ConsumerState { setState(() => _isSaving = true); try { - await ref.read(adminRepositoryProvider).upsertReceiptAlias( - receiptName: rawAlias, - productId: productId, - isGlobal: true, - ); + final repo = ref.read(adminRepositoryProvider); + final isEditing = _editingAliasId != null; + if (isEditing) { + await repo.updateReceiptAlias( + _editingAliasId!, + receiptName: rawAlias, + productId: productId, + ); + } else { + await repo.upsertReceiptAlias( + receiptName: rawAlias, + productId: productId, + isGlobal: true, + ); + } + if (!mounted) return; _aliasController.clear(); - setState(() => _selectedProductId = null); + setState(() { + _selectedProductId = null; + _editingAliasId = null; + }); await _load(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Alias sparad.')), + SnackBar(content: Text(isEditing ? 'Alias uppdaterat.' : 'Alias sparad.')), ); } catch (e) { if (!mounted) return; @@ -102,6 +117,29 @@ class _AdminAliasesPanelState extends ConsumerState { } } + void _startEditAlias(ReceiptAlias alias) { + if (!alias.isGlobal) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Privata alias redigeras av respektive användare.')), + ); + return; + } + + setState(() { + _editingAliasId = alias.id; + _aliasController.text = alias.receiptName; + _selectedProductId = alias.productId; + }); + } + + void _cancelEditAlias() { + setState(() { + _editingAliasId = null; + _aliasController.clear(); + _selectedProductId = null; + }); + } + Future _removeAlias(ReceiptAlias alias) async { final confirmed = await showDialog( context: context, @@ -202,11 +240,23 @@ class _AdminAliasesPanelState extends ConsumerState { ), ], ), - trailing: IconButton( - onPressed: () => _removeAlias(alias), - icon: const Icon(Icons.delete_outline), - tooltip: 'Ta bort alias', - color: Theme.of(context).colorScheme.error, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () => _startEditAlias(alias), + icon: const Icon(Icons.edit_outlined), + tooltip: alias.isGlobal + ? 'Redigera alias' + : 'Privata alias redigeras av användaren', + ), + IconButton( + onPressed: () => _removeAlias(alias), + icon: const Icon(Icons.delete_outline), + tooltip: 'Ta bort alias', + color: Theme.of(context).colorScheme.error, + ), + ], ), ), ); @@ -256,6 +306,7 @@ class _AdminAliasesPanelState extends ConsumerState { const SizedBox(width: 8), Expanded( child: DropdownButtonFormField( + key: ValueKey(_selectedProductId), initialValue: _selectedProductId, decoration: const InputDecoration( labelText: 'Produkt', @@ -275,6 +326,13 @@ class _AdminAliasesPanelState extends ConsumerState { ), ), const SizedBox(width: 8), + if (_editingAliasId != null) ...[ + OutlinedButton( + onPressed: _isSaving ? null : _cancelEditAlias, + child: const Text('Avbryt'), + ), + const SizedBox(width: 8), + ], FilledButton.icon( onPressed: _isSaving ? null : _upsertAlias, icon: _isSaving @@ -283,8 +341,8 @@ class _AdminAliasesPanelState extends ConsumerState { height: 14, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Icon(Icons.save_outlined), - label: const Text('Spara'), + : Icon(_editingAliasId != null ? Icons.edit_outlined : Icons.save_outlined), + label: Text(_editingAliasId != null ? 'Uppdatera' : 'Spara'), ), ], ), diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index a13ba0b0..f9794360 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -498,6 +498,74 @@ class _ReceiptImportTabState extends ConsumerState { setState(() {}); } + Future _editAliasForItem(int index) async { + final items = _items; + if (items == null || index < 0 || index >= items.length) return; + + final item = items[index]; + final edit = _edits[index]; + final productId = edit?.productId; + final initialAlias = item.rawName.trim(); + + if (productId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Välj produkt för raden innan du redigerar alias.')), + ); + return; + } + + final controller = TextEditingController(text: initialAlias); + final aliasName = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Spara alias för raden'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Aliasnamn', + border: OutlineInputBorder(), + ), + onSubmitted: (value) => Navigator.pop(ctx, value.trim()), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Avbryt'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, controller.text.trim()), + child: const Text('Spara'), + ), + ], + ), + ); + controller.dispose(); + + if (!mounted || aliasName == null) return; + if (aliasName.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Aliasnamn kan inte vara tomt.')), + ); + return; + } + + try { + await ref.read(adminRepositoryProvider).upsertReceiptAlias( + receiptName: aliasName, + productId: productId, + isGlobal: false, + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Alias sparat.')), + ); + } catch (e) { + if (!mounted) return; + showGlobalErrorDialog(context, 'Kunde inte uppdatera alias: $e'); + } + } + Future _addSelected() async { final items = _items; if (items == null) return; @@ -844,6 +912,7 @@ class _ReceiptImportTabState extends ConsumerState { i, initialEntryMode: ImportProductEntryMode.create, ), + onAliasEditRequested: () => _editAliasForItem(i), onDeleteRequested: () => _deleteItem(i), matchedViaBadgeBuilder: _buildMatchedViaBadge, ); @@ -877,6 +946,7 @@ class _ReceiptImportResultRow extends ConsumerWidget { final VoidCallback onEditRequested; final VoidCallback onSelectExistingRequested; final VoidCallback onCreateRequested; + final VoidCallback onAliasEditRequested; final VoidCallback onDeleteRequested; final Widget Function(ParsedReceiptItem item, ThemeData theme) matchedViaBadgeBuilder; @@ -891,6 +961,7 @@ class _ReceiptImportResultRow extends ConsumerWidget { required this.onEditRequested, required this.onSelectExistingRequested, required this.onCreateRequested, + required this.onAliasEditRequested, required this.onDeleteRequested, required this.matchedViaBadgeBuilder, }); @@ -1088,7 +1159,7 @@ class _ReceiptImportResultRow extends ConsumerWidget { ], ), trailing: SizedBox( - width: 80, + width: 120, child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -1101,6 +1172,14 @@ class _ReceiptImportResultRow extends ConsumerWidget { : (isSuggested ? Colors.orange : theme.colorScheme.tertiary), size: 20, ), + if (hasProduct) + IconButton( + icon: const Icon(Icons.drive_file_rename_outline, size: 18), + onPressed: onAliasEditRequested, + tooltip: 'Spara alias', + constraints: const BoxConstraints(minWidth: 40, minHeight: 40), + padding: const EdgeInsets.all(4), + ), IconButton( icon: Icon(Icons.delete_outline, size: 18, color: theme.colorScheme.error), onPressed: onDeleteRequested, diff --git a/flutter/lib/features/profile/presentation/user_aliases_screen.dart b/flutter/lib/features/profile/presentation/user_aliases_screen.dart index 0a3c75ea..cf7c4a60 100644 --- a/flutter/lib/features/profile/presentation/user_aliases_screen.dart +++ b/flutter/lib/features/profile/presentation/user_aliases_screen.dart @@ -93,6 +93,68 @@ class _UserAliasesScreenState extends ConsumerState { } } + Future _editAlias(ReceiptAlias alias) async { + if (!alias.isPrivate) return; + + final controller = TextEditingController(text: alias.receiptName); + final newAliasName = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Redigera alias'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Kvittonamn (alias)', + border: OutlineInputBorder(), + ), + onSubmitted: (value) => Navigator.pop(ctx, value.trim()), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Avbryt'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, controller.text.trim()), + child: const Text('Spara'), + ), + ], + ), + ); + + controller.dispose(); + + if (!mounted || newAliasName == null) return; + final normalizedNew = newAliasName.trim(); + if (normalizedNew.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Aliasnamn kan inte vara tomt.')), + ); + return; + } + if (normalizedNew == alias.receiptName.trim()) return; + + try { + final repo = ref.read(adminRepositoryProvider); + await repo.updateReceiptAlias( + alias.id, + receiptName: normalizedNew, + productId: alias.productId, + ); + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Alias uppdaterat.')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Kunde inte uppdatera alias: $e')), + ); + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -242,11 +304,21 @@ class _UserAliasesScreenState extends ConsumerState { ], ), trailing: alias.isPrivate - ? IconButton( - icon: const Icon(Icons.delete_outline), - tooltip: 'Ta bort alias', - color: theme.colorScheme.error, - onPressed: () => _delete(alias), + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit_outlined), + tooltip: 'Redigera alias', + onPressed: () => _editAlias(alias), + ), + IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Ta bort alias', + color: theme.colorScheme.error, + onPressed: () => _delete(alias), + ), + ], ) : null, );