diff --git a/docs/feature-inventory.md b/docs/feature-inventory.md index a6d37b5..4925ea0 100644 --- a/docs/feature-inventory.md +++ b/docs/feature-inventory.md @@ -36,10 +36,13 @@ Räume sind löschbar (Soft-Delete). Jeder Raum hat ein `icon`-Feld (lucide icon | `brand` | string\|null | Marke | | `model` | string\|null | Modell | | `serialNumber` | string\|null | Seriennummer | -| `purchasePrice` | real\|null | Kaufpreis (€) | +| `purchasePrice` | real\|null | Kaufpreis (€), manuell oder via KI-Schätzung übernommen | | `purchaseYear` | integer\|null | Anschaffungsjahr | | `notes` | string\|null | Freies Markdown-Notizfeld | | `coverImageId` | string\|null | Primärbild (→ imageBlobs.id, Server-seitig) | +| `lastPriceEstimate` | real\|null | Letzter geschätzter Marktwert (€) | +| `lastPriceEstimateAt` | string\|null | ISO-Zeitstempel der letzten Schätzung | +| `lastPriceEstimateSource` | `'ebay'`\|`'formula'`\|`'category'`\|null | Quelle der Schätzung | --- @@ -61,34 +64,66 @@ Kein eigenes DB-Table — als TypeScript-Konstante in den jeweiligen Komponenten 3. [Fertig] → Dashboard Route: `/inventory/capture/photo?roomId=...` -KI: POST `/api/vision/recognize` (OpenAI gpt-4o-mini oder Gemini gemini-1.5-flash) +KI: `POST /api/vision/recognize` (OpenAI gpt-4o-mini oder Gemini) +Input: `accept="image/jpeg,image/png,image/webp"` — kein HEIC (Canvas kann HEIC nicht dekodieren) ### Barcode-Modus (Schnellerfassung) 1. Raum wählen (optional via `?roomId=` Query-Parameter) -2. Loop: EAN eingeben → UPCitemdb-Lookup → bestätigen → Asset(draft) +2. Loop: EAN manuell eingeben oder via physischem Scanner → UPCitemdb-Lookup → bestätigen → Asset(draft) 3. [Fertig] → Dashboard Route: `/inventory/capture/barcode?roomId=...` -Lookup: POST `/api/vision/barcode` → UPCitemdb → Open Food Facts → EAN-Fallback +Lookup: `POST /api/vision/barcode` → UPCitemdb → Open Food Facts → EAN als Fallback-Label +Rate-Limit gilt **nicht** für Barcode-Lookups (nutzt UPCitemdb, nicht die Vision-API des Users). ### Manuell (Einzel) -Raum wählen → Titel eingeben → Asset(draft) → Detailseite +Titel eingeben → Asset(draft) → Detailseite Auch via CommandBar: `/asset Titel` --- +## KI-Preisschätzung (enrichAsset) + +Auf der Detailseite: Button "Preis schätzen" → `POST /api/vision/enrich` + +Die KI schätzt auf Basis der vorhandenen Felder: +- `title` (Pflicht) +- `brand`, `model` (optional, verbessern Genauigkeit) + +**Response:** +```json +{ + "brand": "Sony", + "model": "WH-1000XM5", + "estimatedNewPrice": 350, + "estimatedUsedPrice": 180 +} +``` + +Der User kann Marke/Modell und Preise einzeln übernehmen. `purchasePrice` wird auf den +übernommenen Wert gesetzt. `lastPriceEstimate` / `lastPriceEstimateAt` / `lastPriceEstimateSource` +werden beim Speichern der Schätzung aktualisiert. + +Rate-Limit: zählt gegen die 100 Vision-Calls/Tag des Users (gleicher Zähler wie Foto-Erkennung). + +--- + ## Bilder-Architektur **Inventory-Fotos werden NICHT lokal gecacht** (Grund: 500 Assets × ~300 KB = 150 MB). Statt `storeImage()` (schreibt in Dexie): `uploadInventoryImage(blob)`: 1. Client komprimiert: JPEG, max-width 1920px, quality 0.75 → ~300 KB -2. POST `/api/inventory/images` → Server speichert in `imageBlobs`-Tabelle +2. `POST /api/inventory/images` → Server speichert in `imageBlobs`-Tabelle 3. Client erhält `imageId` zurück, speichert nur die ID im Asset 4. Anzeige: `getImageUrl(id)` fetcht live via `/api/sync/blob/{id}` -`storeImage()` aus `imageStore.ts` wird weiterhin für Editor-Paste-Bilder verwendet — -**nicht** für Inventory. +`storeImage()` aus `imageStore.ts` wird weiterhin für Editor-Paste-Bilder verwendet — **nicht** für Inventory. + +### Cover-Konsistenz bei Image-Löschung + +`softDeleteAssetImage` prüft ob `asset.coverImageId === assetImage.imageId`. Wenn ja: +`updateAsset({ coverImageId: nächstesAssetImage?.imageId ?? null })`. --- @@ -96,7 +131,7 @@ Statt `storeImage()` (schreibt in Dexie): `uploadInventoryImage(blob)`: Assets können Familienmitgliedern zugeordnet werden (`assetPersons`-Tabelle, n:m). Auswählbar sind nur Personen mit `contexts.type='person'` + `meta.personSubType='family'`. -Anzeige auf Detailseite als Chips. +Anzeige auf Detailseite als Chips (`PersonMeta.abbreviation` oder `fullName`). --- @@ -106,7 +141,11 @@ Kein globaler API-Key — jeder User hinterlegt seinen eigenen Key in den Einste (Abschnitt "Bild-Erkennung"). Keys werden AES-256-GCM verschlüsselt in `user_settings` gespeichert. Server-seitiges Verschlüsselungs-Secret: `SETTINGS_ENCRYPTION_KEY` in `.env`. -Rate-Limit: 100 Vision-Calls/Tag pro User (DB-basiert via `vision_usage`-Tabelle). +Rate-Limit: 100 Vision-Calls/Tag pro User (`vision_usage`-Tabelle). Gilt für: +- `POST /api/vision/recognize` (Foto-Erkennung) +- `POST /api/vision/enrich` (Preisschätzung) + +Gilt **nicht** für `POST /api/vision/barcode` (UPCitemdb, kein User-API-Key). --- @@ -136,8 +175,9 @@ Rate-Limit: 100 Vision-Calls/Tag pro User (DB-basiert via `vision_usage`-Tabelle ``` POST /api/inventory/images Bild hochladen → { id } -POST /api/vision/recognize Foto → { label, category, candidates } -POST /api/vision/barcode EAN oder Barcode-Foto → { label, category } +POST /api/vision/recognize Foto → { label, category, candidates, remaining } +POST /api/vision/enrich { label, brand?, model? } → { brand, model, estimatedNewPrice, estimatedUsedPrice } +POST /api/vision/barcode { ean } → { label, category? } (kein Rate-Limit) GET /api/vision/settings { provider, apiKeySet, dailyUsage, dailyLimit } PUT /api/vision/settings { provider?, apiKey? } ``` @@ -153,13 +193,6 @@ Neue Tabellen im Sync: `rooms`, `assets`, `assetImages`, `assetPersons`. `storeImage()` und `uploadInventoryImage()` sind NICHT austauschbar: - `storeImage()` → Dexie lokal + Sync (für Editor-Bilder) - `uploadInventoryImage()` → direkt Server, kein Dexie (für Inventory-Fotos) -Verwechslung führt zu 75+ MB lokalem Storage. - -### coverImageId und assetImages -`assets.coverImageId` = das Hauptbild (aus `imageBlobs.id`). -`assetImages` = weitere Bilder (ebenfalls `imageBlobs`-Referenzen). -Das Cover kann auf der Detailseite durch ein `assetImages`-Bild ersetzt werden -(`updateAsset({ coverImageId: assetImage.imageId })`). ### Draft vs. Complete Assets beginnen immer als `draft`. Status `complete` muss manuell gesetzt werden. @@ -171,16 +204,15 @@ Dashboard zeigt Gesamtzahl unabhängig vom Status. Wird nur einmal ausgeführt. Danach kann der User Räume löschen/umbenennen ohne Re-Seed. ### Barcode-Erkennung -Kein client-seitiger Barcode-Scanner. -Stattdessen: EAN manuell eingeben → POST `/api/vision/barcode`. -Server versucht UPCitemdb → Open Food Facts → EAN als Label. +Kein client-seitiger Barcode-Scanner (BarcodeDetector API unterstützt kein iOS/Safari). +EAN manuell eingeben oder physischen Scanner verwenden → `POST /api/vision/barcode`. --- ## Bekannte Einschränkungen - Kamera-Erfassung erfordert Online-Verbindung (kein offline capture) -- Barcode-Lookup max. 100/Tag (UPCitemdb free tier) +- Barcode-Lookup max. 100/Tag server-weit (UPCitemdb free tier) - Räume können nicht per Drag-Drop sortiert werden (`sortOrder` vorhanden, aber kein UI) - Asset-Export (PDF/CSV) nicht implementiert (geplant als spätere Phase) -- Preisrecherche nicht implementiert (geplant: eBay Browse API) +- BarcodeDetector API (live Kamera-Scan) nicht implementiert — iOS unterstützt es nicht diff --git a/ka-note/VERSION b/ka-note/VERSION index b7ae2cf..c6fad92 100644 --- a/ka-note/VERSION +++ b/ka-note/VERSION @@ -1 +1 @@ -1.2.33 \ No newline at end of file +1.2.34 \ No newline at end of file diff --git a/ka-note/server/drizzle/0018_graceful_butterfly.sql b/ka-note/server/drizzle/0018_graceful_butterfly.sql new file mode 100644 index 0000000..d8acf93 --- /dev/null +++ b/ka-note/server/drizzle/0018_graceful_butterfly.sql @@ -0,0 +1,3 @@ +ALTER TABLE `assets` ADD `last_price_estimate` real;--> statement-breakpoint +ALTER TABLE `assets` ADD `last_price_estimate_at` text;--> statement-breakpoint +ALTER TABLE `assets` ADD `last_price_estimate_source` text; \ No newline at end of file diff --git a/ka-note/server/drizzle/meta/0018_snapshot.json b/ka-note/server/drizzle/meta/0018_snapshot.json new file mode 100644 index 0000000..113995a --- /dev/null +++ b/ka-note/server/drizzle/meta/0018_snapshot.json @@ -0,0 +1,1913 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "dba0d183-ce68-4951-a5ab-bda1b48bbd8b", + "prevId": "545c0342-22e0-46aa-b833-ad968f590798", + "tables": { + "ai_locks": { + "name": "ai_locks", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "locked_at": { + "name": "locked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + }, + "api_keys_user_id_idx": { + "name": "api_keys_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "api_keys_key_hash_idx": { + "name": "api_keys_key_hash_idx", + "columns": [ + "key_hash" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "asset_images": { + "name": "asset_images", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_id": { + "name": "image_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purged_at": { + "name": "purged_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "asset_images_updated_at_idx": { + "name": "asset_images_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "asset_images_asset_id_idx": { + "name": "asset_images_asset_id_idx", + "columns": [ + "asset_id" + ], + "isUnique": false + }, + "asset_images_user_id_idx": { + "name": "asset_images_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "asset_images_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "asset_images_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "asset_persons": { + "name": "asset_persons", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "person_id": { + "name": "person_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purged_at": { + "name": "purged_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "asset_persons_updated_at_idx": { + "name": "asset_persons_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "asset_persons_asset_id_idx": { + "name": "asset_persons_asset_id_idx", + "columns": [ + "asset_id" + ], + "isUnique": false + }, + "asset_persons_user_id_idx": { + "name": "asset_persons_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "asset_persons_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "asset_persons_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "assets": { + "name": "assets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "serial_number": { + "name": "serial_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purchase_price": { + "name": "purchase_price", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purchase_year": { + "name": "purchase_year", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_image_id": { + "name": "cover_image_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_price_estimate": { + "name": "last_price_estimate", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_price_estimate_at": { + "name": "last_price_estimate_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_price_estimate_source": { + "name": "last_price_estimate_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purged_at": { + "name": "purged_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "assets_updated_at_idx": { + "name": "assets_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "assets_user_id_idx": { + "name": "assets_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "assets_room_id_idx": { + "name": "assets_room_id_idx", + "columns": [ + "room_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "assets_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "assets_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contexts": { + "name": "contexts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "meta": { + "name": "meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_favorite": { + "name": "is_favorite", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purged_at": { + "name": "purged_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "contexts_updated_at_idx": { + "name": "contexts_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "contexts_user_id_idx": { + "name": "contexts_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "contexts_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "contexts_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "history_entries": { + "name": "history_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "linked_context_id": { + "name": "linked_context_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "done_at": { + "name": "done_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wiedervorlage_date": { + "name": "wiedervorlage_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wiedervorlage_resolved_at": { + "name": "wiedervorlage_resolved_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purged_at": { + "name": "purged_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "history_entries_updated_at_idx": { + "name": "history_entries_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "history_entries_topic_id_idx": { + "name": "history_entries_topic_id_idx", + "columns": [ + "topic_id" + ], + "isUnique": false + }, + "history_entries_user_id_idx": { + "name": "history_entries_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "history_entries_topic_id_user_id_topics_id_user_id_fk": { + "name": "history_entries_topic_id_user_id_topics_id_user_id_fk", + "tableFrom": "history_entries", + "tableTo": "topics", + "columnsFrom": [ + "topic_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "history_entries_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "history_entries_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "image_blobs": { + "name": "image_blobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "image_blobs_user_id_idx": { + "name": "image_blobs_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "image_blobs_content_hash_idx": { + "name": "image_blobs_content_hash_idx", + "columns": [ + "content_hash" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "image_blobs_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "image_blobs_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notebooks": { + "name": "notebooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_favorite": { + "name": "is_favorite", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purged_at": { + "name": "purged_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "notebooks_updated_at_idx": { + "name": "notebooks_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "notebooks_user_id_idx": { + "name": "notebooks_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "notebooks_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "notebooks_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_notebooks": { + "name": "page_notebooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notebook_id": { + "name": "notebook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purged_at": { + "name": "purged_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "page_notebooks_updated_at_idx": { + "name": "page_notebooks_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "page_notebooks_page_id_idx": { + "name": "page_notebooks_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "page_notebooks_notebook_id_idx": { + "name": "page_notebooks_notebook_id_idx", + "columns": [ + "notebook_id" + ], + "isUnique": false + }, + "page_notebooks_user_id_idx": { + "name": "page_notebooks_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "page_notebooks_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "page_notebooks_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pages": { + "name": "pages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "is_private": { + "name": "is_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_favorite": { + "name": "is_favorite", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purged_at": { + "name": "purged_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "pages_updated_at_idx": { + "name": "pages_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "pages_user_id_idx": { + "name": "pages_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "pages_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "pages_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ratings": { + "name": "ratings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "history_entry_id": { + "name": "history_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "person_name": { + "name": "person_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purged_at": { + "name": "purged_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "ratings_updated_at_idx": { + "name": "ratings_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "ratings_topic_id_idx": { + "name": "ratings_topic_id_idx", + "columns": [ + "topic_id" + ], + "isUnique": false + }, + "ratings_user_id_idx": { + "name": "ratings_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ratings_topic_id_user_id_topics_id_user_id_fk": { + "name": "ratings_topic_id_user_id_topics_id_user_id_fk", + "tableFrom": "ratings", + "tableTo": "topics", + "columnsFrom": [ + "topic_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ratings_history_entry_id_user_id_history_entries_id_user_id_fk": { + "name": "ratings_history_entry_id_user_id_history_entries_id_user_id_fk", + "tableFrom": "ratings", + "tableTo": "history_entries", + "columnsFrom": [ + "history_entry_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ratings_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "ratings_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rooms": { + "name": "rooms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_type": { + "name": "group_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purged_at": { + "name": "purged_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "rooms_updated_at_idx": { + "name": "rooms_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "rooms_user_id_idx": { + "name": "rooms_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rooms_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "rooms_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "history_entry_id": { + "name": "history_entry_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purged_at": { + "name": "purged_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "tasks_updated_at_idx": { + "name": "tasks_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "tasks_context_id_idx": { + "name": "tasks_context_id_idx", + "columns": [ + "context_id" + ], + "isUnique": false + }, + "tasks_assignee_idx": { + "name": "tasks_assignee_idx", + "columns": [ + "assignee" + ], + "isUnique": false + }, + "tasks_user_id_idx": { + "name": "tasks_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "tasks_source_external_id_idx": { + "name": "tasks_source_external_id_idx", + "columns": [ + "user_id", + "source", + "external_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tasks_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "tasks_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "topics": { + "name": "topics", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "snooze_until": { + "name": "snooze_until", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_new": { + "name": "is_new", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purged_at": { + "name": "purged_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "topics_updated_at_idx": { + "name": "topics_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "topics_context_id_idx": { + "name": "topics_context_id_idx", + "columns": [ + "context_id" + ], + "isUnique": false + }, + "topics_user_id_idx": { + "name": "topics_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "topics_context_id_user_id_contexts_id_user_id_fk": { + "name": "topics_context_id_user_id_contexts_id_user_id_fk", + "tableFrom": "topics", + "tableTo": "contexts", + "columnsFrom": [ + "context_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "topics_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "topics_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_settings": { + "name": "user_settings", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_settings_user_id_key_pk": { + "columns": [ + "user_id", + "key" + ], + "name": "user_settings_user_id_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vision_usage": { + "name": "vision_usage", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "vision_usage_user_id_date_pk": { + "columns": [ + "user_id", + "date" + ], + "name": "vision_usage_user_id_date_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/ka-note/server/drizzle/meta/_journal.json b/ka-note/server/drizzle/meta/_journal.json index efc6fe9..48b69d6 100644 --- a/ka-note/server/drizzle/meta/_journal.json +++ b/ka-note/server/drizzle/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1772831133949, "tag": "0017_kind_luke_cage", "breakpoints": true + }, + { + "idx": 18, + "version": "6", + "when": 1772869791129, + "tag": "0018_graceful_butterfly", + "breakpoints": true } ] } \ No newline at end of file diff --git a/ka-note/server/ka-note.db-shm b/ka-note/server/ka-note.db-shm index c780ce6..51f55d4 100644 Binary files a/ka-note/server/ka-note.db-shm and b/ka-note/server/ka-note.db-shm differ diff --git a/ka-note/server/ka-note.db-wal b/ka-note/server/ka-note.db-wal index a69425e..3883c28 100644 Binary files a/ka-note/server/ka-note.db-wal and b/ka-note/server/ka-note.db-wal differ diff --git a/ka-note/server/src/db/schema.ts b/ka-note/server/src/db/schema.ts index df4e573..792cb7c 100644 --- a/ka-note/server/src/db/schema.ts +++ b/ka-note/server/src/db/schema.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer, blob, index, primaryKey, foreignKey } from 'drizzle-orm/sqlite-core'; +import { sqliteTable, text, integer, real, blob, index, primaryKey, foreignKey } from 'drizzle-orm/sqlite-core'; export const contexts = sqliteTable('contexts', { id: text('id').notNull(), @@ -236,6 +236,9 @@ export const assets = sqliteTable('assets', { purchaseYear: integer('purchase_year'), notes: text('notes'), coverImageId: text('cover_image_id'), + lastPriceEstimate: real('last_price_estimate'), + lastPriceEstimateAt: text('last_price_estimate_at'), + lastPriceEstimateSource: text('last_price_estimate_source'), updatedAt: text('updated_at').notNull(), deletedAt: text('deleted_at'), purgedAt: text('purged_at'), diff --git a/ka-note/server/src/lib/sync-service.ts b/ka-note/server/src/lib/sync-service.ts index 7ad5bf4..e955f6a 100644 --- a/ka-note/server/src/lib/sync-service.ts +++ b/ka-note/server/src/lib/sync-service.ts @@ -206,6 +206,9 @@ function mapAsset(row: typeof assets.$inferSelect): Asset { purchaseYear: row.purchaseYear ?? null, notes: row.notes ?? null, coverImageId: row.coverImageId ?? null, + lastPriceEstimate: row.lastPriceEstimate ?? null, + lastPriceEstimateAt: row.lastPriceEstimateAt ?? null, + lastPriceEstimateSource: (row.lastPriceEstimateSource ?? null) as Asset['lastPriceEstimateSource'], updatedAt: row.updatedAt, deletedAt: row.deletedAt ?? null, purgedAt: row.purgedAt ?? null, @@ -427,7 +430,7 @@ export async function pushChanges(request: SyncPushRequest, userId: string): Pro } for (const as of assts) { - const row = { id: as.id, userId, roomId: as.roomId ?? null, title: as.title, category: as.category ?? null, status: as.status, condition: as.condition ?? null, brand: as.brand ?? null, model: as.model ?? null, serialNumber: as.serialNumber ?? null, purchasePrice: as.purchasePrice ?? null, purchaseYear: as.purchaseYear ?? null, notes: as.notes ?? null, coverImageId: as.coverImageId ?? null, updatedAt: as.updatedAt, deletedAt: as.deletedAt, purgedAt: as.purgedAt ?? null, version: as.version }; + const row = { id: as.id, userId, roomId: as.roomId ?? null, title: as.title, category: as.category ?? null, status: as.status, condition: as.condition ?? null, brand: as.brand ?? null, model: as.model ?? null, serialNumber: as.serialNumber ?? null, purchasePrice: as.purchasePrice ?? null, purchaseYear: as.purchaseYear ?? null, notes: as.notes ?? null, coverImageId: as.coverImageId ?? null, lastPriceEstimate: as.lastPriceEstimate ?? null, lastPriceEstimateAt: as.lastPriceEstimateAt ?? null, lastPriceEstimateSource: as.lastPriceEstimateSource ?? null, updatedAt: as.updatedAt, deletedAt: as.deletedAt, purgedAt: as.purgedAt ?? null, version: as.version }; if (await upsertEntity(assets, row, conflicts, 'asset', userId)) accepted++; } diff --git a/ka-note/shared/src/types.ts b/ka-note/shared/src/types.ts index 5fb3751..1913650 100644 --- a/ka-note/shared/src/types.ts +++ b/ka-note/shared/src/types.ts @@ -116,6 +116,9 @@ export interface Asset extends SyncEntity { purchaseYear: number | null; notes: string | null; coverImageId: string | null; + lastPriceEstimate: number | null; + lastPriceEstimateAt: string | null; + lastPriceEstimateSource: 'ebay' | 'formula' | 'category' | null; } export interface AssetImage extends SyncEntity { diff --git a/plans/vision-inventory.md b/plans/vision-inventory.md index 24f83fd..f37143d 100644 --- a/plans/vision-inventory.md +++ b/plans/vision-inventory.md @@ -188,7 +188,7 @@ PRIMARY KEY (userId, date) `storeImage()` schreibt immer in Dexie (kein Bypass). 500 × 5 MB = 2,5 GB lokal — nicht akzeptabel. → Neues `uploadInventoryImage(blob): Promise`: - 1. Bild client-seitig komprimieren: `canvas.toBlob('image/jpeg', 0.75)` + max-width 1920px → ~300 KB + 1. Bild client-seitig komprimieren: Canvas `toBlob('image/jpeg', 0.75)` + max-width 1920px → ~300 KB. HEIC wird durch `accept="image/jpeg,image/png,image/webp"` im Input verhindert. 2. Direkt POST zu `/api/inventory/images` (neuer Endpoint, kein Sync-Push) 3. Server speichert in `imageBlobs`, gibt `imageId` zurück 4. Client speichert nur `imageId` im Asset — kein Dexie-Blob @@ -366,7 +366,7 @@ Phase 4 → Phase 7 (Docs) [nach stabilem UI] - Datei: `server/src/routes/vision.ts` (NEU) ``` POST /api/vision/recognize — multipart { image: File } - POST /api/vision/barcode — JSON { ean: string } + POST /api/vision/barcode — JSON { ean: string } ← nur EAN-Lookup, kein Bild-Input GET /api/vision/settings — { provider, apiKeySet, dailyUsage, dailyLimit } PUT /api/vision/settings — { provider?, apiKey? } ``` @@ -374,7 +374,8 @@ Phase 4 → Phase 7 (Docs) [nach stabilem UI] ``` POST /api/inventory/images — multipart { image: File } → speichert in imageBlobs, gibt { id } zurück ``` - Alle Routen: `authMiddleware`. `/api/vision/*` zusätzlich Rate-Limit-Check. + Routen: alle `authMiddleware`. Rate-Limit-Check **nur** auf `POST /api/vision/recognize` — nicht auf `/api/vision/barcode` (nutzt UPCitemdb, nicht die Vision-API des Users). + UPCitemdb-Limit (100/Tag server-weit, free tier) ist ein separates Server-seitiges Limit — nicht via `vision_usage` getrackt. - Datei: `server/src/index.ts`: + 4 Zeilen (beide Routes registrieren) **Verifikation 2:** @@ -404,12 +405,12 @@ Phase 4 → Phase 7 (Docs) [nach stabilem UI] #### Schritt 3.1 — Vision Service Client - Datei: `client/src/lib/services/visionService.ts` (NEU) - - `compressImage(blob): Promise` — HEIC→JPEG + max-width 1920px + quality 0.75 + - `compressImage(blob): Promise` — max-width 1920px + quality 0.75, JPEG-Output via Canvas. HEIC wird durch `accept`-Attribut im Input vermieden (kein HEIC-Decoder nötig). - `uploadInventoryImage(blob): Promise` — POST `/api/inventory/images`, gibt imageId zurück - `recognizePhoto(blob): Promise` — POST `/api/vision/recognize` - `recognizeBarcode(ean: string): Promise` — POST `/api/vision/barcode` - - **Kein ZXing client-seitig** — Barcode-Erkennung läuft server-seitig (via UPCitemdb-Lookup) - - Barcode-Modus: User gibt EAN manuell ein ODER fotografiert Barcode → Server erkennt via KI+Lookup + - **Kein ZXing client-seitig** — EAN wird manuell eingegeben (Nummernpad oder physischer Barcode-Scanner) + - Barcode-via-Foto entfällt aus Phase 3: `POST /api/vision/barcode` akzeptiert nur `{ ean: string }`, kein Bild. Foto→EAN wäre ein separater Endpoint oder via BarcodeDetector API (spätere Phase). - Datei: `client/src/lib/services/visionSettingsService.ts` (NEU) - `getVisionSettings()`, `saveVisionSettings()` - `@zxing/browser` **entfällt** — kein client-seitiger Barcode-Scan @@ -434,12 +435,10 @@ Phase 4 → Phase 7 (Docs) [nach stabilem UI] #### Schritt 3.5 — BarcodeScan Komponente - Datei: `client/src/lib/components/BarcodeScan.svelte` (NEU) - - **Zwei Eingabepfade:** - 1. EAN manuell eingeben (Nummernpad, für Nutzer mit Barcode-Scanner-Gerät) - 2. Foto aufnehmen → Server-Lookup (wie Foto-Modus, aber `/api/vision/barcode` mit Bild) + - **Eingabe:** EAN manuell eingeben (Nummernpad oder physischer Barcode-Scanner via Keyboard-Input) - States: `idle → looking_up → confirm | error` - Props: `onconfirm(label, category, ean?), oncancel` - - **Kein ZXing** — Barcode-Erkennung im Foto via Server (KI-Modell kann Barcodes lesen) + - **Kein Foto-Pfad** in Phase 3 — `POST /api/vision/barcode` akzeptiert nur `{ ean: string }`. Foto→EAN kommt erst mit BarcodeDetector API (spätere Phase). #### Schritt 3.6 — Loop-Seiten - Datei: `client/src/routes/inventory/capture/photo/+page.svelte` (NEU) @@ -513,6 +512,7 @@ Phase 4 → Phase 7 (Docs) [nach stabilem UI] - Inline-Edit alle Felder (sofort gespeichert via `updateAsset`) - Status-Toggle: Draft → Complete - Galerie: `+ Foto hinzufügen` (ohne KI) + Löschen (Soft-Delete `assetImages`) + - **Cover-Konsistenz bei Image-Löschung:** `softDeleteAssetImage` prüft ob `asset.coverImageId === assetImage.imageId` → wenn ja: `updateAsset({ coverImageId: nächstesAssetImage?.imageId ?? null })` - Notizen: `MarkdownEditor.svelte` (bestehende Komponente) - **Personen-Sektion**: "Gehört / zugeordnet" — Chips für zugeordnete Familienmitglieder - `getFamilyPersons()` → Dropdown/Picker mit allen `family`-Personen @@ -816,9 +816,22 @@ Server versucht UPCitemdb → Open Food Facts → EAN als Label. --- -## Spätere Phase: Automatische Preisrecherche +## Spätere Phase: Automatische Preisschätzung -Auf Basis von Marke + Modell (+ optional Seriennummer): +### Stufenmodell — je mehr Daten, desto präziser + +| Verfügbare Daten | Strategie | Quelle | +|---|---|---| +| Nur Label/Kategorie | Kategorie-Durchschnittsrange (hardcoded) | Offline | +| + Brand + Model | Produktsuche → Median aktueller Listings | eBay Browse API | +| + Condition | Zustandsfaktor anwenden | Client-seitig | +| + purchaseYear | Abschreibungsfaktor zusätzlich | Client-seitig | +| + purchasePrice (ohne Markt) | `Kaufpreis × Zeitfaktor × Zustandsfaktor` | Offline-Formel | + +**Zustandsfaktoren:** `new`=100%, `good`=70%, `fair`=45%, `poor`=20% +**Abschreibung:** `-8% pro Jahr` (Elektronik: `-15%`), Minimum 10% Restwert + +### API-Quellen | Dienst | Art | Bewertung | |---|---|---| @@ -826,9 +839,45 @@ Auf Basis von Marke + Modell (+ optional Seriennummer): | **Open EAN / UPCitemdb** | Barcode→Produktinfo | Ergänzend für Basisdaten | | **Google Shopping** (Custom Search) | Preisvergleich | Kostenpflichtig ab Volumen | | **idealo** | Scraping | Fragil + rechtlich grenzwertig → nicht empfohlen | -| **Versicherungswert-Formel** | Offline | `Kaufpreis × Altersfaktor` als schnelle Schätzung | -Umsetzung: Button "Preis recherchieren" auf Detailseite → `GET /api/inventory/[id]/price-lookup` → eBay API Query mit `brand + model` → Median-Preis + Link. +### Endpoint + +`GET /api/inventory/[id]/price-lookup` → Response: +```json +{ + "estimatedValue": 320, + "rangeMin": 240, + "rangeMax": 410, + "confidence": 3, + "source": "ebay", + "breakdown": "Marktpreis 450 € × Zustand 'gut' (70%) = 315 €", + "ebayListingUrl": "https://..." +} +``` + +**Konfidenz-Score (0–3):** +- 0 = nur Label/Kategorie → Range aus hardcodierter Tabelle +- 1 = +Brand+Model → eBay-Suche möglich +- 2 = +Condition → Zustandsfaktor auf eBay-Preis angewendet +- 3 = +Condition+purchaseYear → Abschreibungsfaktor zusätzlich + +`purchaseYear` erhöht Score nur wenn `condition` bereits bekannt (sonst keine Aussagekraft). + +### UI auf Detailseite + +``` +[Preis schätzen ●●●○] ← Konfidenz-Indikator +→ "~320 € (eBay-Marktpreise)" ← mit Quelle +→ "150–400 € (Kategorie-Schätzung, Marke/Modell ergänzen für besseres Ergebnis)" +[Wert übernehmen] → setzt purchasePrice +``` + +Schätzung wird in eigenen Spalten von `assets` gespeichert (nicht in `notes` — strukturierte Daten gehören nicht in Freitext-Felder): +- `lastPriceEstimate REAL` — geschätzter Wert (Median) +- `lastPriceEstimateAt TEXT` — Zeitstempel der letzten Schätzung +- `lastPriceEstimateSource TEXT` — `'ebay'|'formula'|'category'` + +Diese Felder sind Teil des Asset-Datenmodells (Phase 1 Schritt 1.2 + 1.3 entsprechend ergänzen). --- @@ -850,7 +899,7 @@ Umsetzung: Button "Preis recherchieren" auf Detailseite → `GET /api/inventory/ | Risiko | Mitigation | |---|---| -| HEIC von iOS-Kamera | `compressImage()`: Canvas `toBlob('image/jpeg', 0.75)` + max-width 1920px | +| HEIC von iOS-Kamera | Browser kann HEIC nicht nativ via Canvas dekodieren. Fix: `` → iOS bietet dann JPEG an (kein HEIC). Alternativ: `heic2any`-Library als Fallback für direkten File-Drop. Canvas-only reicht **nicht**. | | Kamera-Erfassung erfordert Online | PWA mit WLAN — akzeptabel; Fehlermeldung wenn offline | | OpenAI/Gemini Timeout | 10s Timeout, Fallback: manuelles Label | | Rate-Limit-Zähler bei DB-Fehler | `INSERT OR IGNORE` + UPDATE — Worst case: limit nicht enforced (akzeptabel) | @@ -860,6 +909,74 @@ Umsetzung: Button "Preis recherchieren" auf Detailseite → `GET /api/inventory/ | Vision-API nicht konfiguriert | Fehlermeldung "Bitte API-Key in Einstellungen hinterlegen" statt 500 | | Rate-Limit Timezone-Drift | Server-UTC für `date`-Feld — dokumentiert, kein Fix nötig | +## Spätere Phase: KI-Zustandserkennung aus Foto + +Beim Foto-Erfassungs-Flow erkennt die KI nicht nur Label + Kategorie, sondern auch `condition`. + +Erweiterung des Vision-Prompts: +``` +Return JSON: +{ "label": "...", "category": "...", "condition": "new|good|fair|poor|unknown", "candidates": [...] } +``` + +- `condition` wird in LabelConfirm als vorausgefülltes Dropdown angezeigt (editierbar) +- `"unknown"` ist **kein gültiger** `condition`-Typ — Server-seitig auf `null` mappen bevor Antwort an Client geht +- Kein Breaking Change — bestehende Response ohne `condition` → null + +**Nutzen:** Spart einen manuellen Schritt auf der Detailseite; verbessert Preisschätzungs-Qualität direkt nach Erfassung. + +--- + +## Spätere Phase: Offline-Capture-Queue + +Aktuell: Kamera-Erfassung erfordert Online-Verbindung (hard constraint). + +**Verbesserung:** Fotos offline aufnehmen, in IndexedDB queuen, bei nächster Verbindung hochladen. + +``` +OfflineCaptureQueue (Dexie): + id, blob, roomId, capturedAt, uploadedAt (null = pending) +``` + +- UI zeigt Badge "X Fotos ausstehend" wenn Queue nicht leer +- Background-Sync (Service Worker) oder manueller "Jetzt hochladen"-Button +- Nach Upload: normaler createAsset-Flow mit zurückgegebenem imageId + +**Komplexität:** Mittel (Service Worker optional — auch ohne funktioniert manueller Trigger). + +--- + +## Spätere Phase: BarcodeDetector API (Native Browser) + +Chrome 83+ und Android WebView unterstützen `BarcodeDetector` nativ — **Safari wird nicht unterstützt** (weder iOS noch macOS, Stand 2026). Für iOS-User bleibt das manuelle EAN-Eingabefeld der primäre Pfad. + +```javascript +const detector = new BarcodeDetector({ formats: ['ean_13', 'ean_8', 'upc_a'] }); +const barcodes = await detector.detect(videoFrame); +``` + +- Live-Kamera-Stream → EAN direkt client-seitig extrahiert +- Danach: `POST /api/vision/barcode` mit EAN (wie bisher) für Produktdaten-Lookup +- Fallback für nicht unterstützte Browser: manuelles EAN-Eingabefeld (wie aktuell geplant) +- Kein ZXing-Dependency nötig + +**Nutzen:** Echter Barcode-Scanner-Feeling ohne Bibliothek. + +--- + +## Spätere Phase: Draft-Reminder + +Assets die länger als X Tage als `draft` verbleiben → Hinweis auf Dashboard. + +``` +Dashboard: "5 Gegenstände noch unvollständig (> 7 Tage)" [Jetzt vervollständigen →] +``` + +- Client-seitig via `liveQuery` — kein Server nötig +- Schwellwert: 7 Tage (hardcoded oder in Settings konfigurierbar) + +--- + ## Spätere Phase: Asset-Export Für Versicherungszwecke: