mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
e00e6f5a08
commit
de4f766b06
5 changed files with 92 additions and 10 deletions
|
|
@ -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 +
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue