feat: implement update functionality for receipt aliases and add corresponding tests
Test Suite / backend-pr-quick (24.15.0) (push) Has been skipped
Test Suite / quick-import-pr-quick (24.15.0) (push) Has been skipped
Test Suite / backend-full (24.15.0) (push) Failing after 22s
Test Suite / flutter-quality (push) Failing after 4s

This commit is contained in:
Nils-Johan Gynther
2026-05-12 21:25:48 +02:00
parent fb6b371fb7
commit 46b9be4791
10 changed files with 403 additions and 21 deletions
@@ -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;
}
@@ -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,
@@ -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');
});
});
@@ -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);
});
});
@@ -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,
},
});
}
}
+1
View File
@@ -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';
}
@@ -420,6 +420,19 @@ class AdminRepository {
'isGlobal': isGlobal,
});
Future<void> updateReceiptAlias(
int id, {
String? receiptName,
int? productId,
}) {
final body = <String, dynamic>{
if (receiptName != null) 'receiptName': receiptName,
if (productId != null) 'productId': productId,
};
return _patchVoid(ReceiptAliasApiPaths.update(id), body);
}
Future<void> removeReceiptAlias(int id) =>
_deleteVoid(ReceiptAliasApiPaths.remove(id));
@@ -26,6 +26,7 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
final TextEditingController _aliasController = TextEditingController();
int? _selectedProductId;
int? _editingAliasId;
@override
void initState() {
@@ -79,18 +80,32 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
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<AdminAliasesPanel> {
}
}
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<void> _removeAlias(ReceiptAlias alias) async {
final confirmed = await showDialog<bool>(
context: context,
@@ -202,11 +240,23 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
),
],
),
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<AdminAliasesPanel> {
const SizedBox(width: 8),
Expanded(
child: DropdownButtonFormField<int>(
key: ValueKey<int?>(_selectedProductId),
initialValue: _selectedProductId,
decoration: const InputDecoration(
labelText: 'Produkt',
@@ -275,6 +326,13 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
),
),
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<AdminAliasesPanel> {
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'),
),
],
),
@@ -498,6 +498,74 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
setState(() {});
}
Future<void> _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<String>(
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<void> _addSelected() async {
final items = _items;
if (items == null) return;
@@ -844,6 +912,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
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,
@@ -93,6 +93,68 @@ class _UserAliasesScreenState extends ConsumerState<UserAliasesScreen> {
}
}
Future<void> _editAlias(ReceiptAlias alias) async {
if (!alias.isPrivate) return;
final controller = TextEditingController(text: alias.receiptName);
final newAliasName = await showDialog<String>(
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<UserAliasesScreen> {
],
),
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,
);