Compare commits
2 Commits
a5cd49284a
...
c720f611ea
| Author | SHA1 | Date | |
|---|---|---|---|
| c720f611ea | |||
| e658f2e6f1 |
@@ -43,6 +43,14 @@ MVP ar uppnadd nar en vanlig anvandare kan importera, granska och spara kvitto/r
|
||||
|
||||
## Nyligen klart
|
||||
|
||||
## Utförda steg (2026-05-18)
|
||||
|
||||
- [x] **ESLint i backend + CI:** ESLint-konfiguration tillagd i backend och CI-workflow uppdaterad med lint-step för PR/push.
|
||||
- [x] **Dart lint-konfig aktiverad:** `flutter/analysis_options.yaml` tillagd för att säkerställa `flutter_lints` i analyskörningar.
|
||||
- [x] **Prisma query logging styrbar per miljö:** `PRISMA_LOG_QUERIES` implementerad i backend samt kopplad i `compose.yml`.
|
||||
- [x] **Dokumenterat aktivering av query-loggar:** Instruktion att sätta `PRISMA_LOG_QUERIES=1` och starta om `recipe-api` i test/staging.
|
||||
- [x] **Korrigerat testförväntan i receipt-import:** Security-test för saknat användar-id uppdaterat till `UnauthorizedException`.
|
||||
|
||||
## Utförda steg (2026-05-13)
|
||||
|
||||
- [x] **Centralt hjälptextsystem (backend):** Nytt `HelpTextsModule` med service, controller och DTO. `GET /api/help-texts/:key` returnerar rätt hjälptext baserat på användarroll (prioritetsordning: admin → user → default). `PUT /api/help-texts/:key/:scope` kräver admin-roll.
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
|
||||
# Nyheter och förbättringar (2026-05-18)
|
||||
|
||||
- **CI: ESLint för backend:** ESLint är infört i backend (`backend/eslint.config.mjs`) och körs i GitHub Actions (`.github/workflows/test.yml`) via steget `Lint backend`.
|
||||
- **CI: Dart lints aktiverade:** `flutter/analysis_options.yaml` är tillagd med `include: package:flutter_lints/flutter.yaml`, så `flutter analyze` använder explicita lint-regler.
|
||||
- **Prisma query logging i test/staging:** Backend stödjer nu env-styrd query-loggning via `PRISMA_LOG_QUERIES` i `backend/src/prisma/prisma.service.ts`.
|
||||
- **Compose-stöd för loggning:** `compose.yml` har `PRISMA_LOG_QUERIES: "${PRISMA_LOG_QUERIES:-0}"` för säker default av.
|
||||
- **Testfix receipt-import:** Säkerhetstestet för saknat användar-id i `upsertUnitMapping` är uppdaterat till `UnauthorizedException`, i linje med controllerns beteende.
|
||||
|
||||
# Nyheter och förbättringar (2026-05-13)
|
||||
|
||||
- **Centralt hjälptextsystem:** Nytt backend-modul (`HelpTextsModule`) med `GET /api/help-texts/:key` (rollmedveten) och `PUT /api/help-texts/:key/:scope` (admin). Stöd för scopade hjälptexter: `admin`, `user`, `default` med prioritetsordning beroende på användarroll.
|
||||
|
||||
@@ -16,6 +16,15 @@ Se även: README.md för användarflöde, och AI-FUNKTIONER.md för AI-detaljer.
|
||||
|
||||
# Prisma-migreringar: P3009 recovery och lessons learned
|
||||
|
||||
# Nyheter och förbättringar (2026-05-18)
|
||||
|
||||
- **Backend linting i CI:** ESLint är infört för backend (`backend/eslint.config.mjs`, `npm run lint`) och körs i `.github/workflows/test.yml`.
|
||||
- **Flutter lint-konfiguration:** `flutter/analysis_options.yaml` är tillagd och inkluderar `package:flutter_lints/flutter.yaml`.
|
||||
- **Prisma query logging (miljöstyrd):** `PrismaService` konfigurerar loggnivåer via env-variabeln `PRISMA_LOG_QUERIES`.
|
||||
- **Runtime-konfiguration:** `compose.yml` exponerar `PRISMA_LOG_QUERIES` till `recipe-api` med default `0`.
|
||||
- **Aktivering i testmiljö:** Sätt `PRISMA_LOG_QUERIES=1` och starta om `recipe-api` för att få SQL query-loggar.
|
||||
- **Verifierad testjustering:** `receipt-import.security.spec.ts` validerar nu `UnauthorizedException` vid saknat användar-id i `upsertUnitMapping`.
|
||||
|
||||
# Drift och deploy (2026-05-11)
|
||||
|
||||
- **Flutter build-artifacts:** Byggda filer i `flutter/build/` och `.flutter-plugins-dependencies` ska inte versionshanteras. Vid deploy på server: kör `git restore flutter/build flutter/.flutter-plugins-dependencies` och `git clean -fd flutter/build` innan `git pull`.
|
||||
|
||||
@@ -10,6 +10,8 @@ export type FlyerImportItem = {
|
||||
comparisonPrice: number | null;
|
||||
comparisonUnit: string | null;
|
||||
offerText: string | null;
|
||||
isOffer: boolean;
|
||||
offerLimitText: string | null;
|
||||
parseConfidence: number;
|
||||
parseReasons: string[];
|
||||
matchedProductId: number | null;
|
||||
|
||||
@@ -79,6 +79,7 @@ export class FlyerImportService {
|
||||
|
||||
const items: FlyerImportItem[] = parsed.items.map((item) => {
|
||||
const match = this.matchItem(item, products, aliasToProduct, productById);
|
||||
const offerLimitText = this.extractOfferLimitText(item.offerText);
|
||||
return {
|
||||
flyerItemId: null,
|
||||
rawName: item.rawName,
|
||||
@@ -89,6 +90,8 @@ export class FlyerImportService {
|
||||
comparisonPrice: item.comparisonPrice,
|
||||
comparisonUnit: item.comparisonUnit,
|
||||
offerText: item.offerText,
|
||||
isOffer: this.isOfferItem(item),
|
||||
offerLimitText,
|
||||
parseConfidence: item.confidence,
|
||||
parseReasons: item.reasonCodes,
|
||||
matchedProductId: match.product?.id ?? null,
|
||||
@@ -257,6 +260,29 @@ export class FlyerImportService {
|
||||
return intersection / union;
|
||||
}
|
||||
|
||||
private isOfferItem(item: FlyerParseItem): boolean {
|
||||
return item.price != null || item.comparisonPrice != null || !!item.offerText?.trim();
|
||||
}
|
||||
|
||||
private extractOfferLimitText(offerText: string | null): string | null {
|
||||
if (!offerText) return null;
|
||||
|
||||
const normalized = offerText.replace(/\s+/g, ' ' ).trim();
|
||||
if (!normalized) return null;
|
||||
|
||||
const limitMatch = normalized.match(/(?:max|högst)\s+[^,.;]+(?:hushåll|kund)?/i);
|
||||
if (limitMatch?.[0]) {
|
||||
return limitMatch[0].trim();
|
||||
}
|
||||
|
||||
const householdMatch = normalized.match(/[^,.;]*(?:hushåll|kund)[^,.;]*/i);
|
||||
if (householdMatch?.[0]) {
|
||||
return householdMatch[0].trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async parseViaImporter(file: Express.Multer.File): Promise<FlyerParseResponse> {
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
|
||||
@@ -6,6 +6,12 @@ class FlyerImportItem {
|
||||
final double? price;
|
||||
final String? priceUnit;
|
||||
final String? offerText;
|
||||
final bool isOffer;
|
||||
final String? offerLimitText;
|
||||
final double? comparisonPrice;
|
||||
final String? comparisonUnit;
|
||||
final double? parseConfidence;
|
||||
final List<String> parseReasons;
|
||||
final int? matchedProductId;
|
||||
final String? matchedProductName;
|
||||
final String? matchedVia;
|
||||
@@ -19,6 +25,12 @@ class FlyerImportItem {
|
||||
this.price,
|
||||
this.priceUnit,
|
||||
this.offerText,
|
||||
this.isOffer = false,
|
||||
this.offerLimitText,
|
||||
this.comparisonPrice,
|
||||
this.comparisonUnit,
|
||||
this.parseConfidence,
|
||||
this.parseReasons = const [],
|
||||
this.matchedProductId,
|
||||
this.matchedProductName,
|
||||
this.matchedVia,
|
||||
@@ -34,6 +46,12 @@ class FlyerImportItem {
|
||||
price: (json['price'] as num?)?.toDouble(),
|
||||
priceUnit: json['priceUnit'] as String?,
|
||||
offerText: json['offerText'] as String?,
|
||||
isOffer: json['isOffer'] == true,
|
||||
offerLimitText: json['offerLimitText'] as String?,
|
||||
comparisonPrice: (json['comparisonPrice'] as num?)?.toDouble(),
|
||||
comparisonUnit: json['comparisonUnit'] as String?,
|
||||
parseConfidence: (json['parseConfidence'] as num?)?.toDouble(),
|
||||
parseReasons: (json['parseReasons'] as List?)?.map((e) => e.toString()).toList() ?? const [],
|
||||
matchedProductId: (json['matchedProductId'] as num?)?.toInt(),
|
||||
matchedProductName: json['matchedProductName'] as String?,
|
||||
matchedVia: json['matchedVia'] as String?,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/utils/pdf_opener.dart';
|
||||
import '../../auth/data/auth_providers.dart';
|
||||
import '../data/import_providers.dart';
|
||||
import '../domain/flyer_import_item.dart';
|
||||
@@ -25,7 +26,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
Future<void> _pickFile() async {
|
||||
final result = await FilePicker.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf', 'txt'],
|
||||
allowedExtensions: ['pdf', 'txt', 'png', 'jpg', 'jpeg', 'webp'],
|
||||
withData: true,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
@@ -108,6 +109,91 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
}
|
||||
}
|
||||
|
||||
String _formatPrice(double? price, String? unit) {
|
||||
if (price == null) return '';
|
||||
final raw = price.toStringAsFixed(2).replaceAll('.', ',');
|
||||
final unitPart = (unit != null && unit.trim().isNotEmpty) ? '/${unit.trim()}' : '';
|
||||
return '$raw kr$unitPart';
|
||||
}
|
||||
|
||||
Widget _buildOfferBadge(FlyerImportItem item, ThemeData theme) {
|
||||
final hasOffer = item.isOffer || (item.offerText?.trim().isNotEmpty ?? false) || item.price != null;
|
||||
if (!hasOffer) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: Colors.red.shade200),
|
||||
),
|
||||
child: Text(
|
||||
'ERBJUDANDE',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: Colors.red.shade900,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFlyerPreview(ThemeData theme) {
|
||||
final file = _pickedFile;
|
||||
final bytes = file?.bytes;
|
||||
if (bytes == null) return const SizedBox.shrink();
|
||||
|
||||
final filename = file?.name ?? '';
|
||||
final fallbackExt = filename.contains('.') ? filename.split('.').last : '';
|
||||
final ext = (file?.extension ?? fallbackExt).toLowerCase();
|
||||
final isImage = ['png', 'jpg', 'jpeg', 'webp', 'bmp'].contains(ext);
|
||||
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: Icon(
|
||||
isImage ? Icons.image_outlined : Icons.picture_as_pdf_outlined,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
title: const Text('Flyerförhandsvisning'),
|
||||
subtitle: Text(file?.name ?? ''),
|
||||
trailing: isImage
|
||||
? null
|
||||
: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.open_in_new, size: 16),
|
||||
label: const Text('Visa flyer'),
|
||||
style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||
onPressed: () async {
|
||||
final opened = await openPdfBytes(bytes);
|
||||
if (!context.mounted || opened) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('PDF kan bara öppnas direkt i webbversionen just nu.'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (isImage)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 420),
|
||||
child: Image.memory(bytes, fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -120,14 +206,14 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Ladda upp flyer (PDF/txt), granska rader och planera inköp med ett klick.',
|
||||
'Ladda upp flyer, granska erbjudanden och planera inköp med ett klick.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isLoading ? null : _pickFile,
|
||||
icon: const Icon(Icons.attach_file),
|
||||
label: Text(_pickedFile?.name ?? 'Välj flyerfil'),
|
||||
label: Text(_pickedFile?.name ?? 'Välj flyerfil'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
@@ -135,6 +221,8 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
icon: const Icon(Icons.auto_awesome),
|
||||
label: const Text('Importera flyer'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildFlyerPreview(theme),
|
||||
if (_isLoading) ...[
|
||||
const SizedBox(height: 12),
|
||||
const LinearProgressIndicator(),
|
||||
@@ -154,7 +242,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Text(selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'),
|
||||
child: Text(selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -162,14 +250,29 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
...items.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final item = entry.value;
|
||||
final priceText = _formatPrice(item.price, item.priceUnit);
|
||||
final comparisonText = _formatPrice(item.comparisonPrice, item.comparisonUnit);
|
||||
final limitText = item.offerLimitText?.trim();
|
||||
|
||||
return CheckboxListTile(
|
||||
value: _selected[index] ?? false,
|
||||
onChanged: (value) => setState(() => _selected[index] = value ?? false),
|
||||
title: Text(item.rawName),
|
||||
subtitle: Text([
|
||||
if (item.offerText != null && item.offerText!.isNotEmpty) item.offerText!,
|
||||
if (item.matchedProductName != null) 'Match: ${item.matchedProductName}',
|
||||
].join(' · ')),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(item.rawName)),
|
||||
_buildOfferBadge(item, theme),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (priceText.isNotEmpty) Text('Pris: $priceText'),
|
||||
if (comparisonText.isNotEmpty) Text('Jämförpris: $comparisonText'),
|
||||
if (limitText != null && limitText.isNotEmpty) Text('Begränsning: $limitText'),
|
||||
if ((item.offerText?.trim().isNotEmpty ?? false)) Text(item.offerText!.trim()),
|
||||
if (item.matchedProductName != null) Text('Match: ${item.matchedProductName}'),
|
||||
],
|
||||
),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
);
|
||||
}),
|
||||
@@ -194,3 +297,4 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user