Phases 6-7

This commit is contained in:
Aerilyn Weber 2026-05-14 14:47:23 +09:00
parent 76a516a417
commit 029940b079
111 changed files with 17247 additions and 447 deletions

View file

@ -5,3 +5,6 @@ export * from './cabinet-event.enums.js';
export * from './regimen.enums.js';
export * from './refill.enums.js';
export * from './purchase.enums.js';
export * from './product.enums.js';
export * from './recipe.enums.js';
export * from './pantry.enums.js';

View file

@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import {
StorageLocation,
ItemStatus,
FreshnessUrgency,
FreshnessSource,
FreshnessRuleSource,
} from './pantry.enums.js';
describe('Pantry Enums', () => {
describe('StorageLocation', () => {
it('has all expected values', () => {
expect(Object.values(StorageLocation)).toEqual(['pantry', 'fridge', 'freezer', 'counter']);
});
});
describe('ItemStatus', () => {
it('has all expected values', () => {
expect(Object.values(ItemStatus)).toEqual([
'sealed',
'opened',
'prepared',
'consumed',
'discarded',
'expired',
]);
});
});
describe('FreshnessUrgency', () => {
it('has all expected values', () => {
expect(Object.values(FreshnessUrgency)).toEqual([
'fresh',
'use_soon',
'urgent',
'check',
'expired',
]);
});
});
describe('FreshnessSource', () => {
it('has all expected values', () => {
expect(Object.values(FreshnessSource)).toEqual(['packaging', 'rule', 'manual']);
});
});
describe('FreshnessRuleSource', () => {
it('has all expected values', () => {
expect(Object.values(FreshnessRuleSource)).toEqual(['system', 'household']);
});
});
});

View file

@ -0,0 +1,34 @@
export enum StorageLocation {
PANTRY = 'pantry',
FRIDGE = 'fridge',
FREEZER = 'freezer',
COUNTER = 'counter',
}
export enum ItemStatus {
SEALED = 'sealed',
OPENED = 'opened',
PREPARED = 'prepared',
CONSUMED = 'consumed',
DISCARDED = 'discarded',
EXPIRED = 'expired',
}
export enum FreshnessUrgency {
FRESH = 'fresh',
USE_SOON = 'use_soon',
URGENT = 'urgent',
CHECK = 'check',
EXPIRED = 'expired',
}
export enum FreshnessSource {
PACKAGING = 'packaging',
RULE = 'rule',
MANUAL = 'manual',
}
export enum FreshnessRuleSource {
SYSTEM = 'system',
HOUSEHOLD = 'household',
}

View file

@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { ProductCategory, ServingUnit, ProductSource } from './product.enums.js';
describe(ProductCategory.name, () => {
it('has expected values', () => {
expect(ProductCategory.DAIRY).toBe('dairy');
expect(ProductCategory.MEAT).toBe('meat');
expect(ProductCategory.VEGETABLES).toBe('vegetables');
expect(ProductCategory.OTHER).toBe('other');
});
it('has 20 categories', () => {
expect(Object.values(ProductCategory)).toHaveLength(20);
});
});
describe(ServingUnit.name, () => {
it('has metric and discrete units only', () => {
expect(ServingUnit.GRAMS).toBe('g');
expect(ServingUnit.MILLILITERS).toBe('ml');
expect(ServingUnit.PIECES).toBe('piece');
expect(ServingUnit.SLICES).toBe('slice');
});
it('has exactly 4 units (no imperial)', () => {
expect(Object.values(ServingUnit)).toHaveLength(4);
expect(Object.values(ServingUnit)).not.toContain('oz');
expect(Object.values(ServingUnit)).not.toContain('cup');
expect(Object.values(ServingUnit)).not.toContain('tbsp');
expect(Object.values(ServingUnit)).not.toContain('tsp');
});
});
describe(ProductSource.name, () => {
it('has expected values', () => {
expect(ProductSource.MANUAL).toBe('manual');
expect(ProductSource.BARCODE_LOOKUP).toBe('barcode_lookup');
expect(ProductSource.LLM).toBe('llm');
expect(ProductSource.IMPORT).toBe('import');
});
});

View file

@ -0,0 +1,40 @@
export enum ProductCategory {
DAIRY = 'dairy',
MEAT = 'meat',
POULTRY = 'poultry',
SEAFOOD = 'seafood',
FRUITS = 'fruits',
VEGETABLES = 'vegetables',
GRAINS = 'grains',
LEGUMES = 'legumes',
NUTS_SEEDS = 'nuts_seeds',
OILS_FATS = 'oils_fats',
CONDIMENTS = 'condiments',
SPICES = 'spices',
BEVERAGES = 'beverages',
SNACKS = 'snacks',
FROZEN = 'frozen',
CANNED = 'canned',
BAKERY = 'bakery',
DELI = 'deli',
SUPPLEMENTS = 'supplements',
OTHER = 'other',
}
/**
* Metric and discrete units only. Imperial/volume cooking units (oz, cup, tbsp, tsp)
* are accepted at recipe-input time in Phase 6 and converted before persistence.
*/
export enum ServingUnit {
GRAMS = 'g',
MILLILITERS = 'ml',
PIECES = 'piece',
SLICES = 'slice',
}
export enum ProductSource {
MANUAL = 'manual',
BARCODE_LOOKUP = 'barcode_lookup',
LLM = 'llm',
IMPORT = 'import',
}

View file

@ -0,0 +1,18 @@
import { describe, it, expect } from 'vitest';
import { NutritionWarning } from './recipe.enums.js';
describe(NutritionWarning.name, () => {
it('has expected values', () => {
expect(NutritionWarning.HIGH_CALORIES).toBe('high_calories');
expect(NutritionWarning.HIGH_SODIUM).toBe('high_sodium');
expect(NutritionWarning.HIGH_SUGAR).toBe('high_sugar');
expect(NutritionWarning.HIGH_SATURATED_FAT).toBe('high_saturated_fat');
expect(NutritionWarning.LOW_PROTEIN).toBe('low_protein');
expect(NutritionWarning.LOW_FIBER).toBe('low_fiber');
expect(NutritionWarning.HIGH_CHOLESTEROL).toBe('high_cholesterol');
});
it('has exactly 7 warning types', () => {
expect(Object.values(NutritionWarning)).toHaveLength(7);
});
});

View file

@ -0,0 +1,9 @@
export enum NutritionWarning {
HIGH_CALORIES = 'high_calories',
HIGH_SODIUM = 'high_sodium',
HIGH_SUGAR = 'high_sugar',
HIGH_SATURATED_FAT = 'high_saturated_fat',
LOW_PROTEIN = 'low_protein',
LOW_FIBER = 'low_fiber',
HIGH_CHOLESTEROL = 'high_cholesterol',
}

View file

@ -0,0 +1,17 @@
import type { FreshnessRuleSource, StorageLocation } from '../enums/pantry.enums.js';
import type { ProductCategory } from '../enums/product.enums.js';
export interface FreshnessRule {
id: string;
householdId?: string;
category: ProductCategory;
storageLocation: StorageLocation;
shelfLifeDays: number;
openedLifeDays: number;
freezerLifeDays?: number;
spoilageSignsToCheck: string[];
tips?: string;
source: FreshnessRuleSource;
createdAt: Date;
updatedAt: Date;
}

View file

@ -11,3 +11,7 @@ export * from './store.js';
export * from './medicine-price.js';
export * from './refill.js';
export * from './purchase.js';
export * from './product.js';
export * from './recipe.js';
export * from './pantry.js';
export * from './freshness.js';

View file

@ -0,0 +1,36 @@
import type {
FreshnessUrgency,
FreshnessSource,
ItemStatus,
StorageLocation,
} from '../enums/pantry.enums.js';
import type { ServingUnit } from '../enums/product.enums.js';
export interface FreshnessEstimate {
estimatedExpiryDate: Date;
daysRemaining: number;
urgency: FreshnessUrgency;
source: FreshnessSource;
}
export interface PantryItem {
id: string;
householdId: string;
productId: string;
productName: string;
storageLocation: StorageLocation;
quantity: number;
unit: ServingUnit;
purchaseDate: Date;
expirationDate?: Date;
openedDate?: Date;
preparedDate?: Date;
status: ItemStatus;
freshnessEstimate: FreshnessEstimate;
notes?: string;
purchasePrice?: number;
storeId?: string;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}

View file

@ -0,0 +1,33 @@
import type { ProductCategory, ServingUnit, ProductSource } from '../enums/product.enums.js';
export interface NutritionInfo {
calories: number; // kcal per serving
protein: number; // grams
carbs: number; // grams
fat: number; // grams
fiber?: number; // grams
sugar?: number; // grams
sodium?: number; // mg
saturatedFat?: number; // grams
cholesterol?: number; // mg
}
export interface Product {
id: string;
householdId: string;
name: string;
brand?: string;
barcode?: string; // EAN-13 / UPC-A, digits only
category: ProductCategory;
servingSize: number; // quantity of one serving in `servingUnit`
servingUnit: ServingUnit; // metric or discrete only
densityGPerMl?: number; // optional, enables volume↔mass conversion in Phase 6
nutrition: NutritionInfo; // values are PER serving (size = servingSize servingUnit)
tags: string[];
imageUrl?: string;
source: ProductSource;
createdBy: string; // userId
createdAt: Date;
updatedAt: Date;
deletedAt?: Date; // soft delete
}

View file

@ -0,0 +1,58 @@
import type { NutritionInfo } from './product.js';
import type { NutritionWarning } from '../enums/recipe.enums.js';
/** Units stored in the database — always metric or discrete. */
export type RecipeUnit = 'g' | 'ml' | 'piece' | 'slice';
/** Accepted at recipe input / import only; converted to RecipeUnit before persistence. */
export type ImperialUnit = 'oz' | 'lb' | 'cup' | 'tbsp' | 'tsp' | 'fl_oz';
export interface RecipeIngredient {
productId: string; // Reference to Product
productName: string; // Denormalized for display
quantity: number; // stored in metric (g | ml) or as a discrete count
unit: RecipeUnit; // metric/discrete only after normalization
originalQuantity?: number; // preserved from import (e.g. 1)
originalUnit?: ImperialUnit | RecipeUnit; // preserved from import (e.g. 'cup')
preparation?: string; // e.g., 'diced', 'minced', 'melted'
isOptional: boolean;
nutritionContribution: NutritionInfo; // per-ingredient computed nutrition
}
export interface RecipeStep {
order: number;
instruction: string;
duration?: number; // minutes
tip?: string;
}
export interface RecipeSource {
type: 'manual' | 'url' | 'llm_import' | 'text_import';
url?: string;
importedAt?: Date;
}
export interface Recipe {
id: string;
householdId: string;
name: string;
description?: string;
servings: number;
prepTime?: number; // minutes
cookTime?: number; // minutes
totalTime?: number; // minutes (computed or manual)
ingredients: RecipeIngredient[];
steps: RecipeStep[];
tags: string[];
cuisine?: string;
imageUrl?: string;
source?: RecipeSource;
totalNutrition: NutritionInfo; // denormalized, computed on save
perServingNutrition: NutritionInfo; // denormalized, computed on save
warnings: NutritionWarning[]; // computed on save
isFavorite: boolean;
createdBy: string;
createdAt: Date;
updatedAt: Date;
deletedAt?: Date;
}

View file

@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import {
CreateFreshnessRuleSchema,
UpdateFreshnessRuleSchema,
FreshnessRuleQuerySchema,
} from './freshness-rule.schemas.js';
describe('FreshnessRule Schemas', () => {
describe('CreateFreshnessRuleSchema', () => {
const valid = {
category: 'dairy',
storageLocation: 'fridge',
shelfLifeDays: 14,
openedLifeDays: 7,
};
it('accepts valid input', () => {
const result = CreateFreshnessRuleSchema.parse(valid);
expect(result).toMatchObject(valid);
expect(result.spoilageSignsToCheck).toEqual([]);
});
it('accepts all optional fields', () => {
const full = {
...valid,
freezerLifeDays: 90,
spoilageSignsToCheck: ['smell', 'discoloration'],
tips: 'Keep sealed tightly',
};
expect(CreateFreshnessRuleSchema.parse(full)).toMatchObject(full);
});
it('rejects missing category', () => {
const { category: _, ...rest } = valid;
expect(() => CreateFreshnessRuleSchema.parse(rest)).toThrow();
});
it('rejects invalid category', () => {
expect(() => CreateFreshnessRuleSchema.parse({ ...valid, category: 'candy' })).toThrow();
});
it('rejects zero shelfLifeDays', () => {
expect(() => CreateFreshnessRuleSchema.parse({ ...valid, shelfLifeDays: 0 })).toThrow();
});
it('rejects zero openedLifeDays', () => {
expect(() => CreateFreshnessRuleSchema.parse({ ...valid, openedLifeDays: 0 })).toThrow();
});
});
describe('UpdateFreshnessRuleSchema', () => {
it('accepts partial update', () => {
const result = UpdateFreshnessRuleSchema.parse({ shelfLifeDays: 10 });
expect(result.shelfLifeDays).toBe(10);
});
it('accepts empty object', () => {
expect(UpdateFreshnessRuleSchema.parse({})).toEqual({});
});
});
describe('FreshnessRuleQuerySchema', () => {
it('applies defaults', () => {
const result = FreshnessRuleQuerySchema.parse({});
expect(result.limit).toBe(50);
});
it('accepts all filters', () => {
const input = {
category: 'meat',
storageLocation: 'fridge',
cursor: 'abc',
limit: 25,
};
expect(FreshnessRuleQuerySchema.parse(input)).toMatchObject(input);
});
});
});

View file

@ -0,0 +1,56 @@
import { z } from 'zod/v4';
import { StorageLocation, FreshnessRuleSource } from '../enums/pantry.enums.js';
import { ProductCategory } from '../enums/product.enums.js';
export const CreateFreshnessRuleSchema = z.object({
category: z.nativeEnum(ProductCategory),
storageLocation: z.nativeEnum(StorageLocation),
shelfLifeDays: z.number().int().min(1),
openedLifeDays: z.number().int().min(1),
freezerLifeDays: z.number().int().min(1).optional(),
spoilageSignsToCheck: z.array(z.string().min(1).max(200)).max(20).default([]),
tips: z.string().max(1000).trim().optional(),
});
export const UpdateFreshnessRuleSchema = z.object({
shelfLifeDays: z.number().int().min(1).optional(),
openedLifeDays: z.number().int().min(1).optional(),
freezerLifeDays: z.number().int().min(1).optional(),
spoilageSignsToCheck: z.array(z.string().min(1).max(200)).max(20).optional(),
tips: z.string().max(1000).trim().optional(),
});
export const FreshnessRuleQuerySchema = z.object({
category: z.nativeEnum(ProductCategory).optional(),
storageLocation: z.nativeEnum(StorageLocation).optional(),
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(50),
});
export const FreshnessRuleResponseSchema = z.object({
_id: z.string(),
householdId: z.string().optional(),
category: z.nativeEnum(ProductCategory),
storageLocation: z.nativeEnum(StorageLocation),
shelfLifeDays: z.number(),
openedLifeDays: z.number(),
freezerLifeDays: z.number().optional(),
spoilageSignsToCheck: z.array(z.string()),
tips: z.string().optional(),
source: z.nativeEnum(FreshnessRuleSource),
createdAt: z.string(),
updatedAt: z.string(),
});
export const FreshnessRuleListResponseSchema = z.object({
data: z.array(FreshnessRuleResponseSchema),
pagination: z.object({
cursor: z.string().nullable(),
hasMore: z.boolean(),
}),
});
// Type exports
export type CreateFreshnessRuleInput = z.infer<typeof CreateFreshnessRuleSchema>;
export type UpdateFreshnessRuleInput = z.infer<typeof UpdateFreshnessRuleSchema>;
export type FreshnessRuleQueryInput = z.infer<typeof FreshnessRuleQuerySchema>;

View file

@ -9,3 +9,7 @@ export * from './store.schemas.js';
export * from './medicine-price.schemas.js';
export * from './refill.schemas.js';
export * from './purchase.schemas.js';
export * from './product.schemas.js';
export * from './recipe.schemas.js';
export * from './pantry.schemas.js';
export * from './freshness-rule.schemas.js';

View file

@ -0,0 +1,164 @@
import { describe, it, expect } from 'vitest';
import {
CreatePantryItemSchema,
UpdatePantryItemSchema,
TransitionPantryItemSchema,
BatchTransitionSchema,
PantryQuerySchema,
ExpiringQuerySchema,
WasteStatsQuerySchema,
} from './pantry.schemas.js';
describe('Pantry Schemas', () => {
describe('CreatePantryItemSchema', () => {
const valid = {
productId: 'p1',
storageLocation: 'fridge',
quantity: 2,
unit: 'piece',
};
it('accepts valid input', () => {
expect(CreatePantryItemSchema.parse(valid)).toMatchObject(valid);
});
it('accepts all optional fields', () => {
const full = {
...valid,
purchaseDate: '2024-01-15T00:00:00Z',
expirationDate: '2024-02-15T00:00:00Z',
notes: 'Organic',
purchasePrice: 4.99,
storeId: 's1',
};
expect(CreatePantryItemSchema.parse(full)).toMatchObject(full);
});
it('rejects missing productId', () => {
const { productId: _, ...rest } = valid;
expect(() => CreatePantryItemSchema.parse(rest)).toThrow();
});
it('rejects zero quantity', () => {
expect(() => CreatePantryItemSchema.parse({ ...valid, quantity: 0 })).toThrow();
});
it('rejects negative quantity', () => {
expect(() => CreatePantryItemSchema.parse({ ...valid, quantity: -1 })).toThrow();
});
it('rejects invalid storageLocation', () => {
expect(() => CreatePantryItemSchema.parse({ ...valid, storageLocation: 'garage' })).toThrow();
});
it('rejects negative purchasePrice', () => {
expect(() => CreatePantryItemSchema.parse({ ...valid, purchasePrice: -1 })).toThrow();
});
});
describe('UpdatePantryItemSchema', () => {
it('accepts partial update', () => {
const result = UpdatePantryItemSchema.parse({ quantity: 3 });
expect(result.quantity).toBe(3);
});
it('accepts empty object', () => {
const result = UpdatePantryItemSchema.parse({});
expect(result).toEqual({});
});
it('rejects invalid storageLocation', () => {
expect(() => UpdatePantryItemSchema.parse({ storageLocation: 'attic' })).toThrow();
});
});
describe('TransitionPantryItemSchema', () => {
it('accepts valid transition', () => {
const result = TransitionPantryItemSchema.parse({ status: 'opened' });
expect(result.status).toBe('opened');
});
it('accepts transition with date and notes', () => {
const input = {
status: 'consumed',
date: '2024-01-15T12:00:00Z',
notes: 'Used in soup',
};
expect(TransitionPantryItemSchema.parse(input)).toMatchObject(input);
});
it('rejects sealed status as target', () => {
expect(() => TransitionPantryItemSchema.parse({ status: 'sealed' })).toThrow();
});
it('rejects expired status as target', () => {
expect(() => TransitionPantryItemSchema.parse({ status: 'expired' })).toThrow();
});
});
describe('BatchTransitionSchema', () => {
it('accepts valid batch transition', () => {
const input = {
itemIds: ['id1', 'id2'],
status: 'consumed',
};
expect(BatchTransitionSchema.parse(input)).toMatchObject(input);
});
it('rejects empty itemIds', () => {
expect(() => BatchTransitionSchema.parse({ itemIds: [], status: 'consumed' })).toThrow();
});
it('only allows consumed or discarded', () => {
expect(() => BatchTransitionSchema.parse({ itemIds: ['id1'], status: 'opened' })).toThrow();
});
});
describe('PantryQuerySchema', () => {
it('applies defaults', () => {
const result = PantryQuerySchema.parse({});
expect(result.limit).toBe(20);
});
it('accepts all filters', () => {
const input = {
storageLocation: 'fridge',
status: 'sealed,opened',
urgency: 'urgent',
productId: 'p1',
cursor: 'abc',
limit: 50,
};
expect(PantryQuerySchema.parse(input)).toMatchObject(input);
});
});
describe('ExpiringQuerySchema', () => {
it('applies defaults', () => {
const result = ExpiringQuerySchema.parse({});
expect(result.days).toBe(7);
expect(result.limit).toBe(20);
});
it('rejects days > 365', () => {
expect(() => ExpiringQuerySchema.parse({ days: 400 })).toThrow();
});
});
describe('WasteStatsQuerySchema', () => {
it('defaults to month', () => {
const result = WasteStatsQuerySchema.parse({});
expect(result.period).toBe('month');
});
it('accepts valid periods', () => {
for (const period of ['week', 'month', 'quarter', 'year']) {
expect(WasteStatsQuerySchema.parse({ period }).period).toBe(period);
}
});
it('rejects invalid period', () => {
expect(() => WasteStatsQuerySchema.parse({ period: 'decade' })).toThrow();
});
});
});

View file

@ -0,0 +1,143 @@
import { z } from 'zod/v4';
import {
StorageLocation,
ItemStatus,
FreshnessUrgency,
FreshnessSource,
} from '../enums/pantry.enums.js';
import { ServingUnit } from '../enums/product.enums.js';
// --- PantryItem ---
export const CreatePantryItemSchema = z.object({
productId: z.string().min(1),
storageLocation: z.nativeEnum(StorageLocation),
quantity: z.number().positive(),
unit: z.nativeEnum(ServingUnit),
purchaseDate: z.iso.datetime().optional(),
expirationDate: z.iso.datetime().optional(),
notes: z.string().max(1000).trim().optional(),
purchasePrice: z.number().nonnegative().optional(),
storeId: z.string().min(1).optional(),
});
export const UpdatePantryItemSchema = z.object({
storageLocation: z.nativeEnum(StorageLocation).optional(),
quantity: z.number().positive().optional(),
unit: z.nativeEnum(ServingUnit).optional(),
expirationDate: z.iso.datetime().optional(),
notes: z.string().max(1000).trim().optional(),
});
export const TransitionPantryItemSchema = z.object({
status: z.enum([
ItemStatus.OPENED,
ItemStatus.PREPARED,
ItemStatus.CONSUMED,
ItemStatus.DISCARDED,
]),
date: z.iso.datetime().optional(),
notes: z.string().max(1000).trim().optional(),
});
export const BatchTransitionSchema = z.object({
itemIds: z.array(z.string().min(1)).min(1).max(100),
status: z.enum([ItemStatus.CONSUMED, ItemStatus.DISCARDED]),
date: z.iso.datetime().optional(),
notes: z.string().max(1000).trim().optional(),
});
export const PantryQuerySchema = z.object({
storageLocation: z.nativeEnum(StorageLocation).optional(),
status: z.string().optional(),
urgency: z.string().optional(),
productId: z.string().optional(),
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export const ExpiringQuerySchema = z.object({
days: z.coerce.number().int().min(1).max(365).default(7),
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export const WasteStatsQuerySchema = z.object({
period: z.enum(['week', 'month', 'quarter', 'year']).default('month'),
});
// --- Response schemas ---
const FreshnessEstimateResponseSchema = z.object({
estimatedExpiryDate: z.string(),
daysRemaining: z.number(),
urgency: z.nativeEnum(FreshnessUrgency),
source: z.nativeEnum(FreshnessSource),
});
export const PantryItemResponseSchema = z.object({
_id: z.string(),
householdId: z.string(),
productId: z.string(),
productName: z.string(),
storageLocation: z.nativeEnum(StorageLocation),
quantity: z.number(),
unit: z.string(),
purchaseDate: z.string(),
expirationDate: z.string().optional(),
openedDate: z.string().optional(),
preparedDate: z.string().optional(),
status: z.nativeEnum(ItemStatus),
freshnessEstimate: FreshnessEstimateResponseSchema,
notes: z.string().optional(),
purchasePrice: z.number().optional(),
storeId: z.string().optional(),
createdBy: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
});
export const PantryItemListResponseSchema = z.object({
data: z.array(PantryItemResponseSchema),
pagination: z.object({
cursor: z.string().nullable(),
hasMore: z.boolean(),
}),
});
const WasteCategorySchema = z.object({
category: z.string(),
count: z.number(),
});
const WasteProductSchema = z.object({
productId: z.string(),
productName: z.string(),
count: z.number(),
});
export const WasteStatsResponseSchema = z.object({
period: z.object({
start: z.string(),
end: z.string(),
}),
totalItemsConsumed: z.number(),
totalItemsDiscarded: z.number(),
wastePercentage: z.number(),
topWastedCategories: z.array(WasteCategorySchema),
topWastedProducts: z.array(WasteProductSchema),
});
export const BatchTransitionResponseSchema = z.object({
transitioned: z.number(),
failed: z.number(),
});
// Type exports
export type CreatePantryItemInput = z.infer<typeof CreatePantryItemSchema>;
export type UpdatePantryItemInput = z.infer<typeof UpdatePantryItemSchema>;
export type TransitionPantryItemInput = z.infer<typeof TransitionPantryItemSchema>;
export type BatchTransitionInput = z.infer<typeof BatchTransitionSchema>;
export type PantryQueryInput = z.infer<typeof PantryQuerySchema>;
export type ExpiringQueryInput = z.infer<typeof ExpiringQuerySchema>;
export type WasteStatsQueryInput = z.infer<typeof WasteStatsQuerySchema>;

View file

@ -0,0 +1,75 @@
import { z } from 'zod/v4';
import { ProductCategory, ServingUnit, ProductSource } from '../enums/product.enums.js';
export const NutritionInfoSchema = z.object({
calories: z.number().min(0),
protein: z.number().min(0),
carbs: z.number().min(0),
fat: z.number().min(0),
fiber: z.number().min(0).optional(),
sugar: z.number().min(0).optional(),
sodium: z.number().min(0).optional(),
saturatedFat: z.number().min(0).optional(),
cholesterol: z.number().min(0).optional(),
});
export const CreateProductSchema = z.object({
name: z.string().min(1).max(200).trim(),
brand: z.string().max(200).trim().optional(),
barcode: z
.string()
.regex(/^\d{8,14}$/, 'Barcode must be 8-14 digits')
.optional(),
category: z.nativeEnum(ProductCategory),
servingSize: z.number().positive(),
servingUnit: z.nativeEnum(ServingUnit),
densityGPerMl: z.number().positive().optional(),
nutrition: NutritionInfoSchema,
tags: z.array(z.string().min(1).max(50).trim()).max(20).default([]),
imageUrl: z.url().optional(),
source: z.nativeEnum(ProductSource).default(ProductSource.MANUAL),
});
export const UpdateProductSchema = CreateProductSchema.partial();
export const ProductQuerySchema = z.object({
q: z.string().optional(),
category: z.nativeEnum(ProductCategory).optional(),
tags: z.string().optional(),
barcode: z.string().optional(),
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export const ProductResponseSchema = z.object({
_id: z.string(),
householdId: z.string(),
name: z.string(),
brand: z.string().optional(),
barcode: z.string().optional(),
category: z.string(),
servingSize: z.number(),
servingUnit: z.string(),
densityGPerMl: z.number().optional(),
nutrition: NutritionInfoSchema,
tags: z.array(z.string()),
imageUrl: z.string().optional(),
source: z.string(),
createdBy: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
deletedAt: z.string().optional(),
});
export const ProductListResponseSchema = z.object({
data: z.array(ProductResponseSchema),
pagination: z.object({
cursor: z.string().nullable(),
hasMore: z.boolean(),
total: z.number().optional(),
}),
});
export type CreateProductInput = z.infer<typeof CreateProductSchema>;
export type UpdateProductInput = z.infer<typeof UpdateProductSchema>;
export type ProductQueryInput = z.infer<typeof ProductQuerySchema>;

View file

@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { CreateRecipeSchema, ScaleRecipeSchema, RecipeQuerySchema } from './recipe.schemas.js';
const validIngredient = {
productId: 'prod-1',
productName: 'Chicken Breast',
quantity: 150,
unit: 'g',
};
const validStep = { order: 1, instruction: 'Cook the chicken.' };
describe(CreateRecipeSchema.name, () => {
it('accepts a minimal valid recipe', () => {
const result = CreateRecipeSchema.safeParse({
name: 'Grilled Chicken',
servings: 2,
ingredients: [validIngredient],
steps: [validStep],
});
expect(result.success).toBe(true);
});
it('accepts imperial units in ingredients', () => {
const result = CreateRecipeSchema.safeParse({
name: 'Test Recipe',
servings: 1,
ingredients: [{ ...validIngredient, quantity: 1, unit: 'cup' }],
steps: [validStep],
});
expect(result.success).toBe(true);
});
it('rejects empty ingredients', () => {
const result = CreateRecipeSchema.safeParse({
name: 'Empty',
servings: 2,
ingredients: [],
steps: [validStep],
});
expect(result.success).toBe(false);
});
it('rejects servings < 1', () => {
const result = CreateRecipeSchema.safeParse({
name: 'Bad',
servings: 0,
ingredients: [validIngredient],
steps: [],
});
expect(result.success).toBe(false);
});
it('defaults isFavorite to false', () => {
const result = CreateRecipeSchema.safeParse({
name: 'Test',
servings: 1,
ingredients: [validIngredient],
steps: [],
});
expect(result.success).toBe(true);
if (result.success) expect(result.data.isFavorite).toBe(false);
});
});
describe(ScaleRecipeSchema.name, () => {
it('accepts valid target servings', () => {
expect(ScaleRecipeSchema.safeParse({ targetServings: 4 }).success).toBe(true);
});
it('rejects zero servings', () => {
expect(ScaleRecipeSchema.safeParse({ targetServings: 0 }).success).toBe(false);
});
});
describe(RecipeQuerySchema.name, () => {
it('defaults limit to 20', () => {
const result = RecipeQuerySchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) expect(result.data.limit).toBe(20);
});
it('clamps limit to 100', () => {
const result = RecipeQuerySchema.safeParse({ limit: '200' });
expect(result.success).toBe(false);
});
});

View file

@ -0,0 +1,124 @@
import { z } from 'zod/v4';
import { NutritionWarning } from '../enums/recipe.enums.js';
import { NutritionInfoSchema } from './product.schemas.js';
const RECIPE_UNIT = ['g', 'ml', 'piece', 'slice'] as const;
const IMPERIAL_UNIT = ['oz', 'lb', 'cup', 'tbsp', 'tsp', 'fl_oz'] as const;
const RecipeUnitSchema = z.enum(RECIPE_UNIT);
const ImperialOrRecipeUnitSchema = z.enum([...RECIPE_UNIT, ...IMPERIAL_UNIT]);
const RecipeIngredientInputSchema = z.object({
productId: z.string().min(1),
productName: z.string().min(1).max(200),
quantity: z.number().positive(),
unit: ImperialOrRecipeUnitSchema,
preparation: z.string().max(100).trim().optional(),
isOptional: z.boolean().default(false),
});
const RecipeStepSchema = z.object({
order: z.number().int().min(1),
instruction: z.string().min(1).max(2000).trim(),
duration: z.number().int().min(1).optional(),
tip: z.string().max(500).trim().optional(),
});
const RecipeSourceSchema = z.object({
type: z.enum(['manual', 'url', 'llm_import', 'text_import']),
url: z.url().optional(),
importedAt: z.string().datetime().optional(),
});
export const CreateRecipeSchema = z.object({
name: z.string().min(1).max(200).trim(),
description: z.string().max(2000).trim().optional(),
servings: z.number().int().min(1).max(500),
prepTime: z.number().int().min(0).optional(),
cookTime: z.number().int().min(0).optional(),
totalTime: z.number().int().min(0).optional(),
ingredients: z.array(RecipeIngredientInputSchema).min(1).max(100),
steps: z.array(RecipeStepSchema).max(100),
tags: z.array(z.string().min(1).max(50).trim()).max(30).default([]),
cuisine: z.string().max(100).trim().optional(),
imageUrl: z.url().optional(),
source: RecipeSourceSchema.optional(),
isFavorite: z.boolean().default(false),
});
export const UpdateRecipeSchema = CreateRecipeSchema.partial();
export const ScaleRecipeSchema = z.object({
targetServings: z.number().int().min(1).max(500),
});
export const ImportRecipeTextSchema = z.object({
text: z.string().min(1).max(50000),
});
export const ImportRecipeUrlSchema = z.object({
url: z.url(),
});
export const RecipeQuerySchema = z.object({
q: z.string().optional(),
tags: z.string().optional(),
cuisine: z.string().optional(),
maxCalories: z.coerce.number().int().min(0).optional(),
isFavorite: z.coerce.boolean().optional(),
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
const StoredRecipeIngredientSchema = z.object({
productId: z.string(),
productName: z.string(),
quantity: z.number(),
unit: RecipeUnitSchema,
originalQuantity: z.number().optional(),
originalUnit: ImperialOrRecipeUnitSchema.optional(),
preparation: z.string().optional(),
isOptional: z.boolean(),
nutritionContribution: NutritionInfoSchema,
});
export const RecipeResponseSchema = z.object({
_id: z.string(),
householdId: z.string(),
name: z.string(),
description: z.string().optional(),
servings: z.number(),
prepTime: z.number().optional(),
cookTime: z.number().optional(),
totalTime: z.number().optional(),
ingredients: z.array(StoredRecipeIngredientSchema),
steps: z.array(RecipeStepSchema),
tags: z.array(z.string()),
cuisine: z.string().optional(),
imageUrl: z.string().optional(),
source: RecipeSourceSchema.optional(),
totalNutrition: NutritionInfoSchema,
perServingNutrition: NutritionInfoSchema,
warnings: z.array(z.nativeEnum(NutritionWarning)),
isFavorite: z.boolean(),
createdBy: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
});
export const RecipeListResponseSchema = z.object({
data: z.array(RecipeResponseSchema),
pagination: z.object({
cursor: z.string().nullable(),
hasMore: z.boolean(),
total: z.number().optional(),
}),
});
export type CreateRecipeInput = z.infer<typeof CreateRecipeSchema>;
export type UpdateRecipeInput = z.infer<typeof UpdateRecipeSchema>;
export type RecipeQueryInput = z.infer<typeof RecipeQuerySchema>;
export type ScaleRecipeInput = z.infer<typeof ScaleRecipeSchema>;
export type ImportRecipeTextInput = z.infer<typeof ImportRecipeTextSchema>;
export type ImportRecipeUrlInput = z.infer<typeof ImportRecipeUrlSchema>;
export type RecipeIngredientInput = z.infer<typeof RecipeIngredientInputSchema>;

View file

@ -8,6 +8,7 @@ export default defineConfig({
coverage: {
provider: 'v8',
enabled: false,
all: true,
include: ['src/**/*.ts'],
exclude: [
'src/**/*.test.ts',