From de4f766b0671d5169d406d3c9a23ada18bbfa2a6 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 9 Apr 2026 15:44:55 +0200 Subject: [PATCH] feat(mana/web/nutriphi): extend meal schema (foods + thumbnail) + update mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema additions on LocalMeal: - photoThumbnailUrl: pre-generated mana-media thumbnail URL, used in list views to save bandwidth (full photoUrl stays for the detail view + lightbox) - foods: AnalyzedFood[] (name / quantity / calories) — Gemini Vision already returns this breakdown but the previous flow threw it away - new AnalyzedFood type exported from the barrel Encryption registry: - meals encrypted allowlist now includes 'foods' (food names are user content; aes.ts JSON-stringifies arrays before wrap, so an array value works the same as a string) - registry comment updated to enumerate which photo fields stay plaintext and why New mutation: mealMutations.update(id, dto) for inline meal edits. Patches only the supplied fields, runs encryptRecord on the partial update so encrypted columns stay encrypted, then re-decrypts the merged row to return a plaintext snapshot. queries.ts: new loadMealById(id) helper used by the detail page's inline useLiveQueryWithDefault wrapper (matches the planta DetailView pattern of capturing the route param directly in the closure). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/data/crypto/registry.ts | 18 +++++--- .../web/src/lib/modules/nutriphi/index.ts | 10 +++- .../web/src/lib/modules/nutriphi/mutations.ts | 46 ++++++++++++++++++- .../web/src/lib/modules/nutriphi/queries.ts | 14 ++++++ .../web/src/lib/modules/nutriphi/types.ts | 14 ++++++ 5 files changed, 92 insertions(+), 10 deletions(-) diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 785c4d2f5..9132e47a9 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -124,7 +124,13 @@ export const ENCRYPTION_REGISTRY: Record = { cycleDayLogs: { enabled: true, fields: ['notes', 'mood'] }, // ─── NutriPhi ──────────────────────────────────────────── - // LocalMeal user-typed text → encrypted: description, portionSize. + // LocalMeal user-typed / AI-generated content → encrypted: + // - description, portionSize: free-text, same sensitivity tier + // - foods: AI-identified food items (array of {name, quantity, + // calories}). aes.ts JSON-stringifies before wrap, so an array + // value works the same as a string. The food names are user + // content ("Currywurst Pommes mittel") and deserve the same + // protection as `description`. // Plaintext (intentional): // - mealType / inputType / date / createdAt: structural, used for // filtering and the daily-summary aggregations + calorie-progress @@ -133,12 +139,12 @@ export const ENCRYPTION_REGISTRY: Record = { // - nutrition (object of numbers): same — calorie totals are summed // in pure $derived helpers; encrypting them would defeat the // local-first reactive layer. - // - photoMediaId / photoUrl: opaque pointers to mana-media; the URL - // alone is not PII (anyone with the URL already has the bytes), - // and CAS-deduped media IDs leak no user content. Same rationale - // planta uses for plantPhotos. + // - photoMediaId / photoUrl / photoThumbnailUrl: opaque pointers to + // mana-media; the URL alone is not PII (anyone with the URL + // already has the bytes), and CAS-deduped media IDs leak no user + // content. Same rationale planta uses for plantPhotos. // - confidence (float 0-1): pure metadata about the AI run. - meals: { enabled: true, fields: ['description', 'portionSize'] }, + meals: { enabled: true, fields: ['description', 'portionSize', 'foods'] }, // ─── Planta ────────────────────────────────────────────── // `name` is NOT in the schema index for plants (only isActive + diff --git a/apps/mana/apps/web/src/lib/modules/nutriphi/index.ts b/apps/mana/apps/web/src/lib/modules/nutriphi/index.ts index bc03ef054..ab7af787a 100644 --- a/apps/mana/apps/web/src/lib/modules/nutriphi/index.ts +++ b/apps/mana/apps/web/src/lib/modules/nutriphi/index.ts @@ -5,8 +5,13 @@ export { mealTable, goalTable, nutriFavoriteTable, NUTRIPHI_GUEST_SEED } from './collections'; export * from './queries'; export { mealMutations, photoMutations, textAnalysisMutations } from './mutations'; -export type { CreateMealDto, CreateMealFromPhotoDto, PhotoAnalysisOutcome } from './mutations'; -export type { UploadMealPhotoResult, MealAnalysisResult, AnalyzedFood } from './api'; +export type { + CreateMealDto, + CreateMealFromPhotoDto, + UpdateMealDto, + PhotoAnalysisOutcome, +} from './mutations'; +export type { UploadMealPhotoResult, MealAnalysisResult } from './api'; export type { LocalMeal, LocalGoal, @@ -14,6 +19,7 @@ export type { MealType, InputType, NutritionData, + AnalyzedFood, NutritionProgress, DailySummary, MealWithNutrition, diff --git a/apps/mana/apps/web/src/lib/modules/nutriphi/mutations.ts b/apps/mana/apps/web/src/lib/modules/nutriphi/mutations.ts index bcf3f95fd..32ce1adda 100644 --- a/apps/mana/apps/web/src/lib/modules/nutriphi/mutations.ts +++ b/apps/mana/apps/web/src/lib/modules/nutriphi/mutations.ts @@ -13,7 +13,7 @@ */ import { db } from '$lib/data/database'; -import { encryptRecord } from '$lib/data/crypto'; +import { encryptRecord, decryptRecord } from '$lib/data/crypto'; import { uploadMealPhoto, analyzeMealPhoto, @@ -21,7 +21,7 @@ import { type MealAnalysisResult, type UploadMealPhotoResult, } from './api'; -import type { LocalMeal, MealType, NutritionData } from './types'; +import type { LocalMeal, MealType, NutritionData, AnalyzedFood } from './types'; export interface CreateMealDto { mealType: MealType; @@ -34,7 +34,17 @@ export interface CreateMealDto { export interface CreateMealFromPhotoDto extends CreateMealDto { photoMediaId: string; photoUrl: string; + photoThumbnailUrl?: string | null; confidence: number; + foods?: AnalyzedFood[] | null; +} + +export interface UpdateMealDto { + mealType?: MealType; + description?: string; + nutrition?: NutritionData | null; + portionSize?: string | null; + date?: string; } function todayStr(): string { @@ -56,6 +66,8 @@ export const mealMutations = { nutrition: dto.nutrition ?? null, photoMediaId: null, photoUrl: null, + photoThumbnailUrl: null, + foods: null, createdAt: now, updatedAt: now, }; @@ -79,6 +91,8 @@ export const mealMutations = { nutrition: dto.nutrition ?? null, photoMediaId: dto.photoMediaId, photoUrl: dto.photoUrl, + photoThumbnailUrl: dto.photoThumbnailUrl ?? null, + foods: dto.foods ?? null, createdAt: now, updatedAt: now, }; @@ -88,6 +102,34 @@ export const mealMutations = { return row; }, + /** + * Patch an existing meal. Only the provided fields are updated. + * Returns the decrypted snapshot after the write. + * + * Encryption note: we build a partial update object containing only + * the changed fields, run encryptRecord on it (mutates the encrypted + * fields in place), then Dexie .update() merges it into the row. The + * decryptRecord at the end reads back the full merged row from Dexie + * and decrypts it for the caller. + */ + async update(id: string, dto: UpdateMealDto): Promise { + const updateData: Record = { + updatedAt: new Date().toISOString(), + }; + if (dto.mealType !== undefined) updateData.mealType = dto.mealType; + if (dto.description !== undefined) updateData.description = dto.description.trim(); + if (dto.nutrition !== undefined) updateData.nutrition = dto.nutrition; + if (dto.portionSize !== undefined) updateData.portionSize = dto.portionSize; + if (dto.date !== undefined) updateData.date = dto.date; + + await encryptRecord('meals', updateData); + await db.table('meals').update(id, updateData); + + const updated = await db.table('meals').get(id); + if (!updated) throw new Error('Meal disappeared after update'); + return decryptRecord('meals', { ...updated }); + }, + async delete(id: string): Promise { const now = new Date().toISOString(); await db.table('meals').update(id, { deletedAt: now, updatedAt: now }); diff --git a/apps/mana/apps/web/src/lib/modules/nutriphi/queries.ts b/apps/mana/apps/web/src/lib/modules/nutriphi/queries.ts index 2e123a947..cb6629812 100644 --- a/apps/mana/apps/web/src/lib/modules/nutriphi/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/nutriphi/queries.ts @@ -32,6 +32,8 @@ export function toMealWithNutrition(local: LocalMeal): MealWithNutrition { nutrition: local.nutrition ?? null, photoMediaId: local.photoMediaId ?? null, photoUrl: local.photoUrl ?? null, + photoThumbnailUrl: local.photoThumbnailUrl ?? null, + foods: local.foods ?? null, createdAt: local.createdAt ?? new Date().toISOString(), }; } @@ -48,6 +50,18 @@ export function useAllMeals() { }); } +/** + * Look up a single meal by id and decrypt it. Used by the detail page, + * which inlines its own useLiveQueryWithDefault wrapper so the querier + * can capture the route param directly (matches planta DetailView pattern). + */ +export async function loadMealById(id: string): Promise { + const local = await db.table('meals').get(id); + if (!local || local.deletedAt) return null; + const [decrypted] = await decryptRecords('meals', [local]); + return decrypted ? toMealWithNutrition(decrypted) : null; +} + /** All goals, auto-updates on any change. */ export function useAllGoals() { return liveQuery(async () => { diff --git a/apps/mana/apps/web/src/lib/modules/nutriphi/types.ts b/apps/mana/apps/web/src/lib/modules/nutriphi/types.ts index 84eea42b8..b06b02c9b 100644 --- a/apps/mana/apps/web/src/lib/modules/nutriphi/types.ts +++ b/apps/mana/apps/web/src/lib/modules/nutriphi/types.ts @@ -16,6 +16,13 @@ export interface NutritionData { sugar: number; } +/** A single food item identified by Gemini Vision in a meal photo. */ +export interface AnalyzedFood { + name: string; + quantity?: string | null; + calories?: number | null; +} + export interface LocalMeal extends BaseRecord { date: string; mealType: MealType; @@ -25,7 +32,12 @@ export interface LocalMeal extends BaseRecord { confidence: number; nutrition?: NutritionData | null; photoMediaId?: string | null; + /** Full-resolution media URL — used in the detail view + lightbox. */ photoUrl?: string | null; + /** Pre-generated thumbnail URL — used in list views to save bandwidth. */ + photoThumbnailUrl?: string | null; + /** AI-identified individual food items. Encrypted (food names = user content). */ + foods?: AnalyzedFood[] | null; } export interface LocalGoal extends BaseRecord { @@ -69,5 +81,7 @@ export interface MealWithNutrition { nutrition: NutritionData | null; photoMediaId?: string | null; photoUrl?: string | null; + photoThumbnailUrl?: string | null; + foods?: AnalyzedFood[] | null; createdAt: string; }