feat: implement update functionality for receipt aliases and add corresponding tests
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
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,12 +240,24 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: IconButton(
|
||||
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(
|
||||
? 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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user