74f95daab2
- Merge leftovers into existing meal_box when same recipeId + cookedAt date + frozen + available. - Store mealBoxMutations JSONB on cooking_sessions for deterministic undo. - Undo decrements portions/remaining, discards box at zero, appends correction ledger rows. - Mobile: undo button in cooking/[id].tsx after-flow and meal-boxes.tsx within 24h window. - i18n undo strings across all 12 locales; parity test green. - 6 new integration tests: merge, no cross-date merge, frozen split, undo restore, undo discard, ledger invariant. - Update FAS3 audit doc with 3d semantics. Closes Fas 3d
76 lines
3.0 KiB
TypeScript
76 lines
3.0 KiB
TypeScript
import type { EventType, Unit } from "@app/shared-types";
|
|
|
|
/**
|
|
* Typade payloads för domänhändelser (spec §55).
|
|
* Events skrivs i outbox-tabellen (domain_events) i samma transaktion som
|
|
* affärsdatan och konsumeras asynkront av worker (memory, analytics, training).
|
|
*/
|
|
export interface EventPayloadMap {
|
|
PRODUCT_ADDED: {
|
|
inventoryItemId: string;
|
|
canonicalIngredientId?: string | null;
|
|
quantity: number;
|
|
unit: Unit;
|
|
source: string;
|
|
};
|
|
PRODUCT_UPDATED: { inventoryItemId: string; changes: Record<string, unknown> };
|
|
PRODUCT_CONSUMED: {
|
|
inventoryItemId: string;
|
|
quantity: number;
|
|
unit: Unit;
|
|
refType?: string | null;
|
|
};
|
|
PRODUCT_DISCARDED: {
|
|
inventoryItemId: string;
|
|
quantity: number;
|
|
unit: Unit;
|
|
valueMinor?: number | null;
|
|
reason?: string | null;
|
|
};
|
|
RECIPE_COOKED: { recipeId: string; portions: number; mealBoxPortions: number };
|
|
RECIPE_RATED: { recipeId: string; stars: number; feedbackTags: string[] };
|
|
RECIPE_CREATED: { recipeId: string; sourceType: string };
|
|
RECIPE_FORKED: { recipeId: string; forkedFromRecipeId: string };
|
|
MEAL_LOGGED: { mealId: string; mealType: string; kcal: number; source: string };
|
|
MEAL_PHOTO_ANALYZED: { scanJobId: string; matched: boolean; kcalMostLikely?: number | null };
|
|
HOUSEHOLD_MEMBER_ADDED: { householdId: string; newUserId: string; role: string };
|
|
SUBSCRIPTION_STARTED: { subscriptionId: string; plan: string; provider: string };
|
|
SUBSCRIPTION_CHANGED: { subscriptionId: string; status: string };
|
|
AI_CORRECTED: { scanJobId?: string | null; taskType: string; field: string };
|
|
MEMORY_UPDATED: { memoryItemId: string; kind: string; origin: string };
|
|
SHOPPING_COMPLETED: { shoppingListId: string; itemsAdded: number };
|
|
WEEK_PLAN_UPDATED: { weekPlanId: string; reason?: string | null };
|
|
MEAL_BOX_CREATED: { mealBoxId: string; portions: number };
|
|
MEAL_BOX_UPDATED: { mealBoxId: string; addedPortions: number; totalPortions: number };
|
|
MEAL_BOX_CONSUMED: { mealBoxId: string; portions: number };
|
|
MEAL_BOX_DISCARDED: { mealBoxId: string; portions: number; source: string };
|
|
MEAL_REMOVED: { mealId: string; recipeId: string; source: string };
|
|
COOKING_SESSION_UNDONE: {
|
|
cookingSessionId: string;
|
|
recipeId: string;
|
|
reversedTransactions: number;
|
|
};
|
|
}
|
|
|
|
/** Kontroll i kompileringstid att kartan täcker alla EventType. */
|
|
type AssertCoversAll = EventType extends keyof EventPayloadMap ? true : never;
|
|
const _coversAll: AssertCoversAll = true;
|
|
void _coversAll;
|
|
|
|
export interface NewDomainEvent<T extends EventType = EventType> {
|
|
type: T;
|
|
payload: T extends keyof EventPayloadMap ? EventPayloadMap[T] : never;
|
|
userId?: string | undefined;
|
|
householdId?: string | undefined;
|
|
correlationId?: string | undefined;
|
|
}
|
|
|
|
/** Hjälpare med full typinferens: makeEvent("RECIPE_COOKED", {...}). */
|
|
export function makeEvent<T extends EventType>(
|
|
type: T,
|
|
payload: T extends keyof EventPayloadMap ? EventPayloadMap[T] : never,
|
|
scope: { userId?: string; householdId?: string; correlationId?: string } = {},
|
|
): NewDomainEvent<T> {
|
|
return { type, payload, ...scope };
|
|
}
|