diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 67daaf7a..6d715e4c 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -48,7 +48,7 @@ jobs: - name: Prisma schema validate working-directory: ./backend - run: npx prisma validate --schema prisma/schema.prisma + run: npm run prisma:validate - name: Generate Prisma Client working-directory: ./backend @@ -66,6 +66,10 @@ jobs: 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 + - name: Typecheck backend + working-directory: ./backend + run: npm run typecheck + - name: Build NestJS app working-directory: ./backend run: npm run build @@ -154,7 +158,7 @@ jobs: - name: Prisma schema validate working-directory: ./backend - run: npx prisma validate --schema prisma/schema.prisma + run: npm run prisma:validate - name: Generate Prisma Client working-directory: ./backend @@ -170,7 +174,11 @@ jobs: - name: Dependency audit (high+critical) 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) working-directory: ./backend diff --git a/backend/package.json b/backend/package.json index 712066ee..c9fb08ee 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,8 +7,12 @@ "start": "node dist/main", "start:dev": "nest start --watch", "prisma:generate": "prisma generate", + "prisma:validate": "prisma validate --schema prisma/schema.prisma", "prisma:migrate": "prisma migrate dev", "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:watch": "jest --watch" }, diff --git a/backend/src/receipt-alias/dto/update-receipt-alias.dto.ts b/backend/src/receipt-alias/dto/update-receipt-alias.dto.ts index 8aed9e39..770434e3 100644 --- a/backend/src/receipt-alias/dto/update-receipt-alias.dto.ts +++ b/backend/src/receipt-alias/dto/update-receipt-alias.dto.ts @@ -1,4 +1,4 @@ -import { IsInt, IsOptional, IsString, MinLength } from 'class-validator'; +import { IsBoolean, IsInt, IsOptional, IsString, MinLength } from 'class-validator'; export class UpdateReceiptAliasDto { @IsOptional() @@ -9,4 +9,8 @@ export class UpdateReceiptAliasDto { @IsOptional() @IsInt() productId?: number; + + @IsOptional() + @IsBoolean() + isGlobal?: boolean; } diff --git a/backend/src/receipt-alias/receipt-alias.security.spec.ts b/backend/src/receipt-alias/receipt-alias.security.spec.ts index 4ecd0f15..8db29266 100644 --- a/backend/src/receipt-alias/receipt-alias.security.spec.ts +++ b/backend/src/receipt-alias/receipt-alias.security.spec.ts @@ -47,4 +47,13 @@ describe('ReceiptAlias controller security', () => { 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'); + }); }); diff --git a/backend/src/receipt-alias/receipt-alias.service.spec.ts b/backend/src/receipt-alias/receipt-alias.service.spec.ts index 48a60a59..9591b184 100644 --- a/backend/src/receipt-alias/receipt-alias.service.spec.ts +++ b/backend/src/receipt-alias/receipt-alias.service.spec.ts @@ -88,10 +88,67 @@ describe('ReceiptAliasService', () => { data: { receiptName: 'arla mjolk 1l', 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 () => { prismaMock.receiptAlias.findUnique.mockResolvedValue({ id: 12, diff --git a/backend/src/receipt-alias/receipt-alias.service.ts b/backend/src/receipt-alias/receipt-alias.service.ts index 81ed28de..3b66b131 100644 --- a/backend/src/receipt-alias/receipt-alias.service.ts +++ b/backend/src/receipt-alias/receipt-alias.service.ts @@ -114,8 +114,8 @@ export class ReceiptAliasService { 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.'); + if (dto.receiptName == null && dto.productId == null && dto.isGlobal == null) { + throw new BadRequestException('Inget att uppdatera. Ange receiptName, productId eller isGlobal.'); } let nextReceiptName = alias.receiptName; @@ -128,13 +128,27 @@ export class ReceiptAliasService { } const nextProductId = dto.productId ?? alias.productId; + const nextIsGlobal = dto.isGlobal ?? alias.isGlobal; - // Skydda mot krock i samma scope när receiptName ändras. - if (nextReceiptName !== alias.receiptName) { + if (dto.isGlobal != null && dto.isGlobal !== alias.isGlobal && role !== 'admin') { + 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({ - where: alias.isGlobal + where: nextIsGlobal ? { receiptName: nextReceiptName, isGlobal: true } - : { receiptName: nextReceiptName, ownerId: alias.ownerId, isGlobal: false }, + : { receiptName: nextReceiptName, ownerId: nextOwnerId, isGlobal: false }, }); if (conflict && conflict.id !== alias.id) { @@ -147,6 +161,8 @@ export class ReceiptAliasService { data: { receiptName: nextReceiptName, productId: nextProductId, + isGlobal: nextIsGlobal, + ownerId: nextOwnerId, }, }); } diff --git a/flutter/lib/features/admin/data/admin_repository.dart b/flutter/lib/features/admin/data/admin_repository.dart index 67d0c958..13752572 100644 --- a/flutter/lib/features/admin/data/admin_repository.dart +++ b/flutter/lib/features/admin/data/admin_repository.dart @@ -424,10 +424,12 @@ class AdminRepository { int id, { String? receiptName, int? productId, + bool? isGlobal, }) { final body = { if (receiptName != null) 'receiptName': receiptName, if (productId != null) 'productId': productId, + if (isGlobal != null) 'isGlobal': isGlobal, }; return _patchVoid(ReceiptAliasApiPaths.update(id), body); diff --git a/flutter/lib/features/admin/presentation/admin_aliases_panel.dart b/flutter/lib/features/admin/presentation/admin_aliases_panel.dart index 65ac00df..67424d4a 100644 --- a/flutter/lib/features/admin/presentation/admin_aliases_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_aliases_panel.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -23,6 +25,9 @@ class _AdminAliasesPanelState extends ConsumerState { String _search = ''; List _aliases = []; List _products = []; + int? _scopeChangedAliasId; + bool? _scopeChangedToGlobal; + Timer? _scopeChangedTimer; final TextEditingController _aliasController = TextEditingController(); int? _selectedProductId; @@ -35,10 +40,26 @@ class _AdminAliasesPanelState extends ConsumerState { @override void dispose() { + _scopeChangedTimer?.cancel(); _aliasController.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 _load() async { setState(() { _isLoading = true; @@ -109,6 +130,7 @@ class _AdminAliasesPanelState extends ConsumerState { Future _editAlias(ReceiptAlias alias) async { String aliasName = alias.receiptName; int selectedProductId = alias.productId; + bool isGlobal = alias.isGlobal; final nameController = TextEditingController(text: alias.receiptName); final result = await showDialog( @@ -147,6 +169,23 @@ class _AdminAliasesPanelState extends ConsumerState { 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: [ @@ -167,6 +206,7 @@ class _AdminAliasesPanelState extends ConsumerState { if (result != true || !mounted) return; final trimmedAlias = aliasName.trim(); + final scopeChanged = isGlobal != alias.isGlobal; if (trimmedAlias.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Aliasnamn kan inte vara tomt.')), @@ -180,10 +220,14 @@ class _AdminAliasesPanelState extends ConsumerState { alias.id, receiptName: trimmedAlias, productId: selectedProductId, + isGlobal: isGlobal, ); if (!mounted) return; await _load(); if (!mounted) return; + if (scopeChanged) { + _markScopeChanged(alias.id, isGlobal); + } ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Alias uppdaterat.')), ); @@ -296,6 +340,16 @@ class _AdminAliasesPanelState extends ConsumerState { visualDensity: VisualDensity.compact, 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(