feat: enhance receipt alias management with global scope support and update validation
This commit is contained in:
@@ -48,7 +48,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Prisma schema validate
|
- name: Prisma schema validate
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
run: npx prisma validate --schema prisma/schema.prisma
|
run: npm run prisma:validate
|
||||||
|
|
||||||
- name: Generate Prisma Client
|
- name: Generate Prisma Client
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
@@ -66,6 +66,10 @@ jobs:
|
|||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
run: npx jest src/receipt-import/receipt-import.service.spec.ts src/receipt-import/receipt-import.parse-flow.spec.ts src/receipt-import/receipt-import.save.spec.ts --no-coverage
|
run: npx jest src/receipt-import/receipt-import.service.spec.ts src/receipt-import/receipt-import.parse-flow.spec.ts src/receipt-import/receipt-import.save.spec.ts --no-coverage
|
||||||
|
|
||||||
|
- name: Typecheck backend
|
||||||
|
working-directory: ./backend
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
- name: Build NestJS app
|
- name: Build NestJS app
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
run: npm run build
|
run: npm run build
|
||||||
@@ -154,7 +158,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Prisma schema validate
|
- name: Prisma schema validate
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
run: npx prisma validate --schema prisma/schema.prisma
|
run: npm run prisma:validate
|
||||||
|
|
||||||
- name: Generate Prisma Client
|
- name: Generate Prisma Client
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
@@ -170,7 +174,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Dependency audit (high+critical)
|
- name: Dependency audit (high+critical)
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
run: npm audit --audit-level=high
|
run: npm run audit:high
|
||||||
|
|
||||||
|
- name: Typecheck backend
|
||||||
|
working-directory: ./backend
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
- name: Run tests (backend)
|
- name: Run tests (backend)
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
|
|||||||
@@ -7,8 +7,12 @@
|
|||||||
"start": "node dist/main",
|
"start": "node dist/main",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:validate": "prisma validate --schema prisma/schema.prisma",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:deploy": "prisma migrate deploy",
|
"prisma:deploy": "prisma migrate deploy",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"audit:high": "npm audit --audit-level=high",
|
||||||
|
"quality:ci": "npm run prisma:validate && npm run prisma:generate && npm run typecheck && npm test && npm run build && npm run audit:high",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch"
|
"test:watch": "jest --watch"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsInt, IsOptional, IsString, MinLength } from 'class-validator';
|
import { IsBoolean, IsInt, IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateReceiptAliasDto {
|
export class UpdateReceiptAliasDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -9,4 +9,8 @@ export class UpdateReceiptAliasDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
productId?: number;
|
productId?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isGlobal?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,4 +47,13 @@ describe('ReceiptAlias controller security', () => {
|
|||||||
|
|
||||||
expect(receiptAliasServiceMock.update).toHaveBeenCalledWith(10, dto, 42, 'user');
|
expect(receiptAliasServiceMock.update).toHaveBeenCalledWith(10, dto, 42, 'user');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('update skickar med isGlobal i dto', () => {
|
||||||
|
const dto = { receiptName: 'Arla mjolk 1l', productId: 7, isGlobal: true };
|
||||||
|
receiptAliasServiceMock.update.mockResolvedValue({ id: 10 });
|
||||||
|
|
||||||
|
controller.update(10, dto as any, { userId: 42, role: 'admin' });
|
||||||
|
|
||||||
|
expect(receiptAliasServiceMock.update).toHaveBeenCalledWith(10, dto, 42, 'admin');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,10 +88,67 @@ describe('ReceiptAliasService', () => {
|
|||||||
data: {
|
data: {
|
||||||
receiptName: 'arla mjolk 1l',
|
receiptName: 'arla mjolk 1l',
|
||||||
productId: 8,
|
productId: 8,
|
||||||
|
isGlobal: false,
|
||||||
|
ownerId: 10,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('tillåter admin att ändra alias från privat till global', 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, isGlobal: true, ownerId: null });
|
||||||
|
|
||||||
|
await service.update(12, { isGlobal: true }, 1, 'admin');
|
||||||
|
|
||||||
|
expect(prismaMock.receiptAlias.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: { receiptName: 'mjolk 1l', isGlobal: true },
|
||||||
|
});
|
||||||
|
expect(prismaMock.receiptAlias.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 12 },
|
||||||
|
data: {
|
||||||
|
receiptName: 'mjolk 1l',
|
||||||
|
productId: 7,
|
||||||
|
isGlobal: true,
|
||||||
|
ownerId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blockerar vanlig användare från att ändra alias till globalt', async () => {
|
||||||
|
prismaMock.receiptAlias.findUnique.mockResolvedValue({
|
||||||
|
id: 12,
|
||||||
|
receiptName: 'mjolk 1l',
|
||||||
|
productId: 7,
|
||||||
|
ownerId: 10,
|
||||||
|
isGlobal: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.update(12, { isGlobal: true }, 10, 'user')).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blockerar global till privat när alias saknar owner', async () => {
|
||||||
|
prismaMock.receiptAlias.findUnique.mockResolvedValue({
|
||||||
|
id: 12,
|
||||||
|
receiptName: 'mjolk 1l',
|
||||||
|
productId: 7,
|
||||||
|
ownerId: null,
|
||||||
|
isGlobal: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.update(12, { isGlobal: false }, 1, 'admin')).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('blockerar update när aliasnamn krockar i samma scope', async () => {
|
it('blockerar update när aliasnamn krockar i samma scope', async () => {
|
||||||
prismaMock.receiptAlias.findUnique.mockResolvedValue({
|
prismaMock.receiptAlias.findUnique.mockResolvedValue({
|
||||||
id: 12,
|
id: 12,
|
||||||
|
|||||||
@@ -114,8 +114,8 @@ export class ReceiptAliasService {
|
|||||||
throw new ForbiddenException('Du har inte behörighet att redigera aliaset');
|
throw new ForbiddenException('Du har inte behörighet att redigera aliaset');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.receiptName == null && dto.productId == null) {
|
if (dto.receiptName == null && dto.productId == null && dto.isGlobal == null) {
|
||||||
throw new BadRequestException('Inget att uppdatera. Ange receiptName eller productId.');
|
throw new BadRequestException('Inget att uppdatera. Ange receiptName, productId eller isGlobal.');
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextReceiptName = alias.receiptName;
|
let nextReceiptName = alias.receiptName;
|
||||||
@@ -128,13 +128,27 @@ export class ReceiptAliasService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nextProductId = dto.productId ?? alias.productId;
|
const nextProductId = dto.productId ?? alias.productId;
|
||||||
|
const nextIsGlobal = dto.isGlobal ?? alias.isGlobal;
|
||||||
|
|
||||||
// Skydda mot krock i samma scope när receiptName ändras.
|
if (dto.isGlobal != null && dto.isGlobal !== alias.isGlobal && role !== 'admin') {
|
||||||
if (nextReceiptName !== alias.receiptName) {
|
throw new ForbiddenException('Endast admin kan ändra aliasets scope (privat/global).');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextOwnerId = nextIsGlobal ? null : alias.ownerId;
|
||||||
|
if (!nextIsGlobal && nextOwnerId == null) {
|
||||||
|
throw new BadRequestException('Kan inte göra globalt alias privat utan ägare.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skydda mot krock i samma scope när namn eller scope ändras.
|
||||||
|
if (
|
||||||
|
nextReceiptName !== alias.receiptName ||
|
||||||
|
nextIsGlobal !== alias.isGlobal ||
|
||||||
|
nextOwnerId !== alias.ownerId
|
||||||
|
) {
|
||||||
const conflict = await this.prisma.receiptAlias.findFirst({
|
const conflict = await this.prisma.receiptAlias.findFirst({
|
||||||
where: alias.isGlobal
|
where: nextIsGlobal
|
||||||
? { receiptName: nextReceiptName, isGlobal: true }
|
? { receiptName: nextReceiptName, isGlobal: true }
|
||||||
: { receiptName: nextReceiptName, ownerId: alias.ownerId, isGlobal: false },
|
: { receiptName: nextReceiptName, ownerId: nextOwnerId, isGlobal: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (conflict && conflict.id !== alias.id) {
|
if (conflict && conflict.id !== alias.id) {
|
||||||
@@ -147,6 +161,8 @@ export class ReceiptAliasService {
|
|||||||
data: {
|
data: {
|
||||||
receiptName: nextReceiptName,
|
receiptName: nextReceiptName,
|
||||||
productId: nextProductId,
|
productId: nextProductId,
|
||||||
|
isGlobal: nextIsGlobal,
|
||||||
|
ownerId: nextOwnerId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -424,10 +424,12 @@ class AdminRepository {
|
|||||||
int id, {
|
int id, {
|
||||||
String? receiptName,
|
String? receiptName,
|
||||||
int? productId,
|
int? productId,
|
||||||
|
bool? isGlobal,
|
||||||
}) {
|
}) {
|
||||||
final body = <String, dynamic>{
|
final body = <String, dynamic>{
|
||||||
if (receiptName != null) 'receiptName': receiptName,
|
if (receiptName != null) 'receiptName': receiptName,
|
||||||
if (productId != null) 'productId': productId,
|
if (productId != null) 'productId': productId,
|
||||||
|
if (isGlobal != null) 'isGlobal': isGlobal,
|
||||||
};
|
};
|
||||||
|
|
||||||
return _patchVoid(ReceiptAliasApiPaths.update(id), body);
|
return _patchVoid(ReceiptAliasApiPaths.update(id), body);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
@@ -23,6 +25,9 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
String _search = '';
|
String _search = '';
|
||||||
List<ReceiptAlias> _aliases = [];
|
List<ReceiptAlias> _aliases = [];
|
||||||
List<AdminProduct> _products = [];
|
List<AdminProduct> _products = [];
|
||||||
|
int? _scopeChangedAliasId;
|
||||||
|
bool? _scopeChangedToGlobal;
|
||||||
|
Timer? _scopeChangedTimer;
|
||||||
|
|
||||||
final TextEditingController _aliasController = TextEditingController();
|
final TextEditingController _aliasController = TextEditingController();
|
||||||
int? _selectedProductId;
|
int? _selectedProductId;
|
||||||
@@ -35,10 +40,26 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_scopeChangedTimer?.cancel();
|
||||||
_aliasController.dispose();
|
_aliasController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _markScopeChanged(int aliasId, bool isGlobal) {
|
||||||
|
_scopeChangedTimer?.cancel();
|
||||||
|
setState(() {
|
||||||
|
_scopeChangedAliasId = aliasId;
|
||||||
|
_scopeChangedToGlobal = isGlobal;
|
||||||
|
});
|
||||||
|
_scopeChangedTimer = Timer(const Duration(seconds: 6), () {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_scopeChangedAliasId = null;
|
||||||
|
_scopeChangedToGlobal = null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
@@ -109,6 +130,7 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
Future<void> _editAlias(ReceiptAlias alias) async {
|
Future<void> _editAlias(ReceiptAlias alias) async {
|
||||||
String aliasName = alias.receiptName;
|
String aliasName = alias.receiptName;
|
||||||
int selectedProductId = alias.productId;
|
int selectedProductId = alias.productId;
|
||||||
|
bool isGlobal = alias.isGlobal;
|
||||||
final nameController = TextEditingController(text: alias.receiptName);
|
final nameController = TextEditingController(text: alias.receiptName);
|
||||||
|
|
||||||
final result = await showDialog<bool>(
|
final result = await showDialog<bool>(
|
||||||
@@ -147,6 +169,23 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
setDialogState(() => selectedProductId = value);
|
setDialogState(() => selectedProductId = value);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SwitchListTile.adaptive(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Globalt alias'),
|
||||||
|
subtitle: Text(
|
||||||
|
alias.isGlobal
|
||||||
|
? 'Aliaset är redan globalt.'
|
||||||
|
: 'Du kan göra privata alias globala.',
|
||||||
|
),
|
||||||
|
value: isGlobal,
|
||||||
|
onChanged: alias.isGlobal
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
if (!value) return;
|
||||||
|
setDialogState(() => isGlobal = true);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -167,6 +206,7 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
if (result != true || !mounted) return;
|
if (result != true || !mounted) return;
|
||||||
|
|
||||||
final trimmedAlias = aliasName.trim();
|
final trimmedAlias = aliasName.trim();
|
||||||
|
final scopeChanged = isGlobal != alias.isGlobal;
|
||||||
if (trimmedAlias.isEmpty) {
|
if (trimmedAlias.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Aliasnamn kan inte vara tomt.')),
|
const SnackBar(content: Text('Aliasnamn kan inte vara tomt.')),
|
||||||
@@ -180,10 +220,14 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
alias.id,
|
alias.id,
|
||||||
receiptName: trimmedAlias,
|
receiptName: trimmedAlias,
|
||||||
productId: selectedProductId,
|
productId: selectedProductId,
|
||||||
|
isGlobal: isGlobal,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await _load();
|
await _load();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
if (scopeChanged) {
|
||||||
|
_markScopeChanged(alias.id, isGlobal);
|
||||||
|
}
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Alias uppdaterat.')),
|
const SnackBar(content: Text('Alias uppdaterat.')),
|
||||||
);
|
);
|
||||||
@@ -296,6 +340,16 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
label: Text(alias.isGlobal ? 'Global' : 'Privat'),
|
label: Text(alias.isGlobal ? 'Global' : 'Privat'),
|
||||||
),
|
),
|
||||||
|
if (_scopeChangedAliasId == alias.id && _scopeChangedToGlobal != null)
|
||||||
|
Chip(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
avatar: const Icon(Icons.sync_alt, size: 16),
|
||||||
|
label: Text(
|
||||||
|
_scopeChangedToGlobal == true
|
||||||
|
? 'Bytt till Global'
|
||||||
|
: 'Bytt till Privat',
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
|
|||||||
Reference in New Issue
Block a user