Phases 6-7
This commit is contained in:
parent
76a516a417
commit
029940b079
111 changed files with 17247 additions and 447 deletions
|
|
@ -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';
|
||||
|
|
|
|||
53
packages/shared/src/enums/pantry.enums.test.ts
Normal file
53
packages/shared/src/enums/pantry.enums.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
34
packages/shared/src/enums/pantry.enums.ts
Normal file
34
packages/shared/src/enums/pantry.enums.ts
Normal 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',
|
||||
}
|
||||
41
packages/shared/src/enums/product.enums.test.ts
Normal file
41
packages/shared/src/enums/product.enums.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
40
packages/shared/src/enums/product.enums.ts
Normal file
40
packages/shared/src/enums/product.enums.ts
Normal 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',
|
||||
}
|
||||
18
packages/shared/src/enums/recipe.enums.test.ts
Normal file
18
packages/shared/src/enums/recipe.enums.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
9
packages/shared/src/enums/recipe.enums.ts
Normal file
9
packages/shared/src/enums/recipe.enums.ts
Normal 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',
|
||||
}
|
||||
17
packages/shared/src/types/freshness.ts
Normal file
17
packages/shared/src/types/freshness.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
36
packages/shared/src/types/pantry.ts
Normal file
36
packages/shared/src/types/pantry.ts
Normal 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;
|
||||
}
|
||||
33
packages/shared/src/types/product.ts
Normal file
33
packages/shared/src/types/product.ts
Normal 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
|
||||
}
|
||||
58
packages/shared/src/types/recipe.ts
Normal file
58
packages/shared/src/types/recipe.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
56
packages/shared/src/validation/freshness-rule.schemas.ts
Normal file
56
packages/shared/src/validation/freshness-rule.schemas.ts
Normal 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>;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
164
packages/shared/src/validation/pantry.schemas.test.ts
Normal file
164
packages/shared/src/validation/pantry.schemas.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
143
packages/shared/src/validation/pantry.schemas.ts
Normal file
143
packages/shared/src/validation/pantry.schemas.ts
Normal 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>;
|
||||
75
packages/shared/src/validation/product.schemas.ts
Normal file
75
packages/shared/src/validation/product.schemas.ts
Normal 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>;
|
||||
87
packages/shared/src/validation/recipe.schemas.test.ts
Normal file
87
packages/shared/src/validation/recipe.schemas.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
124
packages/shared/src/validation/recipe.schemas.ts
Normal file
124
packages/shared/src/validation/recipe.schemas.ts
Normal 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>;
|
||||
|
|
@ -8,6 +8,7 @@ export default defineConfig({
|
|||
coverage: {
|
||||
provider: 'v8',
|
||||
enabled: false,
|
||||
all: true,
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: [
|
||||
'src/**/*.test.ts',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue