feat(mana/web/nutriphi): extend meal schema (foods + thumbnail) + update mutation

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 15:44:55 +02:00
parent e00e6f5a08
commit de4f766b06
5 changed files with 92 additions and 10 deletions

View file

@ -124,7 +124,13 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
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<string, EncryptionConfig> = {
// - 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 +

View file

@ -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,

View file

@ -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<LocalMeal> {
const updateData: Record<string, unknown> = {
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<LocalMeal>('meals').get(id);
if (!updated) throw new Error('Meal disappeared after update');
return decryptRecord('meals', { ...updated });
},
async delete(id: string): Promise<void> {
const now = new Date().toISOString();
await db.table('meals').update(id, { deletedAt: now, updatedAt: now });

View file

@ -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<MealWithNutrition | null> {
const local = await db.table<LocalMeal>('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 () => {

View file

@ -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;
}