Phases 6-7
This commit is contained in:
parent
76a516a417
commit
029940b079
111 changed files with 17247 additions and 447 deletions
124
packages/web/src/services/__tests__/pantry.test.ts
Normal file
124
packages/web/src/services/__tests__/pantry.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockGet, mockPost, mockPatch, mockDelete } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockPatch: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api-client', () => ({
|
||||
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
|
||||
}));
|
||||
|
||||
import {
|
||||
listPantryItems,
|
||||
getPantryItem,
|
||||
createPantryItem,
|
||||
updatePantryItem,
|
||||
transitionPantryItem,
|
||||
batchTransitionPantryItems,
|
||||
getExpiringSoon,
|
||||
getWasteStats,
|
||||
deletePantryItem,
|
||||
} from '../pantry';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('pantry service', () => {
|
||||
it('listPantryItems with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listPantryItems('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry');
|
||||
});
|
||||
|
||||
it('listPantryItems builds query string', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listPantryItems('hh1', {
|
||||
storageLocation: 'fridge',
|
||||
status: 'sealed',
|
||||
urgency: 'urgent',
|
||||
productId: 'p1',
|
||||
limit: 10,
|
||||
});
|
||||
const url = mockGet.mock.calls[0][0] as string;
|
||||
expect(url).toContain('storageLocation=fridge');
|
||||
expect(url).toContain('status=sealed');
|
||||
expect(url).toContain('urgency=urgent');
|
||||
expect(url).toContain('productId=p1');
|
||||
expect(url).toContain('limit=10');
|
||||
});
|
||||
|
||||
it('listPantryItems with cursor', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listPantryItems('hh1', { cursor: 'cur1' });
|
||||
expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('cursor=cur1'));
|
||||
});
|
||||
|
||||
it('getPantryItem calls GET', async () => {
|
||||
mockGet.mockResolvedValue({ _id: 'item1' });
|
||||
await getPantryItem('hh1', 'item1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry/item1');
|
||||
});
|
||||
|
||||
it('createPantryItem calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'item1' });
|
||||
const data = { productId: 'p1', storageLocation: 'fridge', quantity: 1, unit: 'piece' };
|
||||
await createPantryItem('hh1', data as never);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/pantry', data);
|
||||
});
|
||||
|
||||
it('updatePantryItem calls PATCH', async () => {
|
||||
mockPatch.mockResolvedValue({ _id: 'item1' });
|
||||
await updatePantryItem('hh1', 'item1', { quantity: 2 });
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/pantry/item1', { quantity: 2 });
|
||||
});
|
||||
|
||||
it('transitionPantryItem calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'item1' });
|
||||
await transitionPantryItem('hh1', 'item1', { status: 'opened' as never });
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/pantry/item1/transition', {
|
||||
status: 'opened',
|
||||
});
|
||||
});
|
||||
|
||||
it('batchTransitionPantryItems calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ transitioned: 2, failed: 0 });
|
||||
const data = { itemIds: ['a', 'b'], status: 'consumed' as const };
|
||||
await batchTransitionPantryItems('hh1', data as never);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/pantry/batch-transition', data);
|
||||
});
|
||||
|
||||
it('getExpiringSoon with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await getExpiringSoon('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry/expiring-soon');
|
||||
});
|
||||
|
||||
it('getExpiringSoon builds query string', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await getExpiringSoon('hh1', { days: 3, cursor: 'c1', limit: 5 });
|
||||
const url = mockGet.mock.calls[0][0] as string;
|
||||
expect(url).toContain('days=3');
|
||||
expect(url).toContain('cursor=c1');
|
||||
expect(url).toContain('limit=5');
|
||||
});
|
||||
|
||||
it('getWasteStats with no period', async () => {
|
||||
mockGet.mockResolvedValue({ wastePercentage: 10 });
|
||||
await getWasteStats('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry/stats');
|
||||
});
|
||||
|
||||
it('getWasteStats with period', async () => {
|
||||
mockGet.mockResolvedValue({ wastePercentage: 10 });
|
||||
await getWasteStats('hh1', 'week');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/pantry/stats?period=week');
|
||||
});
|
||||
|
||||
it('deletePantryItem calls DELETE', async () => {
|
||||
mockDelete.mockResolvedValue(undefined);
|
||||
await deletePantryItem('hh1', 'item1');
|
||||
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/pantry/item1');
|
||||
});
|
||||
});
|
||||
163
packages/web/src/services/__tests__/products.test.ts
Normal file
163
packages/web/src/services/__tests__/products.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockGet, mockPost, mockPatch, mockDelete } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockPatch: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api-client', () => ({
|
||||
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
|
||||
}));
|
||||
|
||||
import {
|
||||
listProducts,
|
||||
getProduct,
|
||||
lookupBarcode,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
deleteProduct,
|
||||
smartAddProduct,
|
||||
importProducts,
|
||||
} from '../products';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('products service', () => {
|
||||
it('listProducts with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listProducts('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/products');
|
||||
});
|
||||
|
||||
it('listProducts builds query string', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listProducts('hh1', {
|
||||
q: 'chicken',
|
||||
category: 'meat',
|
||||
tags: 'organic',
|
||||
barcode: '1234',
|
||||
cursor: 'cur1',
|
||||
limit: 10,
|
||||
});
|
||||
const url = mockGet.mock.calls[0][0] as string;
|
||||
expect(url).toContain('q=chicken');
|
||||
expect(url).toContain('category=meat');
|
||||
expect(url).toContain('tags=organic');
|
||||
expect(url).toContain('barcode=1234');
|
||||
expect(url).toContain('cursor=cur1');
|
||||
expect(url).toContain('limit=10');
|
||||
});
|
||||
|
||||
it('getProduct calls GET with correct path', async () => {
|
||||
mockGet.mockResolvedValue({ _id: 'p1' });
|
||||
await getProduct('hh1', 'p1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/products/p1');
|
||||
});
|
||||
|
||||
it('lookupBarcode calls GET barcode endpoint', async () => {
|
||||
mockGet.mockResolvedValue({ _id: 'p1' });
|
||||
await lookupBarcode('hh1', '1234567890');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/products/barcode/1234567890');
|
||||
});
|
||||
|
||||
it('createProduct calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'p1' });
|
||||
const data = {
|
||||
name: 'Chicken',
|
||||
category: 'meat' as never,
|
||||
servingSize: 100,
|
||||
servingUnit: 'g' as never,
|
||||
nutrition: { calories: 165, protein: 31, carbs: 0, fat: 3.6 },
|
||||
tags: [],
|
||||
source: 'manual' as never,
|
||||
};
|
||||
await createProduct('hh1', data);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/products', data);
|
||||
});
|
||||
|
||||
it('updateProduct calls PATCH', async () => {
|
||||
mockPatch.mockResolvedValue({ _id: 'p1' });
|
||||
await updateProduct('hh1', 'p1', { name: 'Updated' });
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/products/p1', { name: 'Updated' });
|
||||
});
|
||||
|
||||
it('deleteProduct calls DELETE', async () => {
|
||||
mockDelete.mockResolvedValue(undefined);
|
||||
await deleteProduct('hh1', 'p1');
|
||||
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/products/p1');
|
||||
});
|
||||
|
||||
it('smartAddProduct calls POST smart-add', async () => {
|
||||
mockPost.mockResolvedValue({ available: false, message: 'LLM not configured' });
|
||||
const result = await smartAddProduct('hh1', 'chicken breast');
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/products/smart-add', {
|
||||
text: 'chicken breast',
|
||||
});
|
||||
expect(result.available).toBe(false);
|
||||
});
|
||||
|
||||
describe('importProducts', () => {
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
it('uploads file via fetch and returns result', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ imported: 3, skippedDuplicates: 1, errors: [] }),
|
||||
});
|
||||
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
const result = await importProducts('hh1', file);
|
||||
|
||||
expect(result.imported).toBe(3);
|
||||
expect(result.skippedDuplicates).toBe(1);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/households/hh1/products/import'),
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on non-ok response with JSON body', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
json: () => Promise.resolve({ message: 'File too large' }),
|
||||
});
|
||||
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
await expect(importProducts('hh1', file)).rejects.toThrow('File too large');
|
||||
});
|
||||
|
||||
it('throws with status on non-ok response without JSON', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.reject(new Error('not json')),
|
||||
});
|
||||
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
await expect(importProducts('hh1', file)).rejects.toThrow(
|
||||
'Import failed: 500 Internal Server Error',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws with status when JSON body has no message', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 422,
|
||||
statusText: 'Unprocessable Entity',
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
|
||||
const file = new File(['data'], 'products.csv', { type: 'text/csv' });
|
||||
await expect(importProducts('hh1', file)).rejects.toThrow('Import failed: 422');
|
||||
});
|
||||
});
|
||||
});
|
||||
118
packages/web/src/services/__tests__/recipes.test.ts
Normal file
118
packages/web/src/services/__tests__/recipes.test.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockGet, mockPost, mockPatch, mockDelete } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockPatch: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api-client', () => ({
|
||||
apiClient: { get: mockGet, post: mockPost, patch: mockPatch, delete: mockDelete },
|
||||
}));
|
||||
|
||||
import {
|
||||
listRecipes,
|
||||
getRecipe,
|
||||
createRecipe,
|
||||
updateRecipe,
|
||||
deleteRecipe,
|
||||
scaleRecipe,
|
||||
importRecipeFromText,
|
||||
importRecipeFromUrl,
|
||||
listRecipesByProduct,
|
||||
} from '../recipes';
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('recipes service', () => {
|
||||
it('listRecipes with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listRecipes('hh1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/recipes');
|
||||
});
|
||||
|
||||
it('listRecipes builds query string', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listRecipes('hh1', {
|
||||
q: 'pasta',
|
||||
tags: 'italian',
|
||||
cuisine: 'Italian',
|
||||
maxCalories: 500,
|
||||
isFavorite: true,
|
||||
cursor: 'cur1',
|
||||
limit: 10,
|
||||
});
|
||||
const url = mockGet.mock.calls[0][0] as string;
|
||||
expect(url).toContain('q=pasta');
|
||||
expect(url).toContain('tags=italian');
|
||||
expect(url).toContain('cuisine=Italian');
|
||||
expect(url).toContain('maxCalories=500');
|
||||
expect(url).toContain('isFavorite=true');
|
||||
expect(url).toContain('cursor=cur1');
|
||||
expect(url).toContain('limit=10');
|
||||
});
|
||||
|
||||
it('getRecipe calls GET', async () => {
|
||||
mockGet.mockResolvedValue({ _id: 'r1' });
|
||||
await getRecipe('hh1', 'r1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/recipes/r1');
|
||||
});
|
||||
|
||||
it('createRecipe calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'r1' });
|
||||
const data = { name: 'Pasta' };
|
||||
await createRecipe('hh1', data as never);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/recipes', data);
|
||||
});
|
||||
|
||||
it('updateRecipe calls PATCH', async () => {
|
||||
mockPatch.mockResolvedValue({ _id: 'r1' });
|
||||
await updateRecipe('hh1', 'r1', { name: 'Updated' } as never);
|
||||
expect(mockPatch).toHaveBeenCalledWith('/households/hh1/recipes/r1', { name: 'Updated' });
|
||||
});
|
||||
|
||||
it('deleteRecipe calls DELETE', async () => {
|
||||
mockDelete.mockResolvedValue(undefined);
|
||||
await deleteRecipe('hh1', 'r1');
|
||||
expect(mockDelete).toHaveBeenCalledWith('/households/hh1/recipes/r1');
|
||||
});
|
||||
|
||||
it('scaleRecipe calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ _id: 'r1' });
|
||||
await scaleRecipe('hh1', 'r1', { targetServings: 8 });
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/recipes/r1/scale', {
|
||||
targetServings: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it('importRecipeFromText calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ available: false });
|
||||
await importRecipeFromText('hh1', { text: 'recipe text' } as never);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/recipes/import-text', {
|
||||
text: 'recipe text',
|
||||
});
|
||||
});
|
||||
|
||||
it('importRecipeFromUrl calls POST', async () => {
|
||||
mockPost.mockResolvedValue({ available: false });
|
||||
await importRecipeFromUrl('hh1', { url: 'http://example.com' } as never);
|
||||
expect(mockPost).toHaveBeenCalledWith('/households/hh1/recipes/import-url', {
|
||||
url: 'http://example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('listRecipesByProduct with no query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listRecipesByProduct('hh1', 'p1');
|
||||
expect(mockGet).toHaveBeenCalledWith('/households/hh1/recipes/by-product/p1');
|
||||
});
|
||||
|
||||
it('listRecipesByProduct with query', async () => {
|
||||
mockGet.mockResolvedValue({ data: [] });
|
||||
await listRecipesByProduct('hh1', 'p1', { cursor: 'c1', limit: 5 });
|
||||
const url = mockGet.mock.calls[0][0] as string;
|
||||
expect(url).toContain('cursor=c1');
|
||||
expect(url).toContain('limit=5');
|
||||
});
|
||||
});
|
||||
113
packages/web/src/services/pantry.ts
Normal file
113
packages/web/src/services/pantry.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { apiClient } from './api-client';
|
||||
import type { z } from 'zod/v4';
|
||||
import type {
|
||||
PantryItemResponseSchema,
|
||||
PantryItemListResponseSchema,
|
||||
WasteStatsResponseSchema,
|
||||
BatchTransitionResponseSchema,
|
||||
CreatePantryItemInput,
|
||||
UpdatePantryItemInput,
|
||||
TransitionPantryItemInput,
|
||||
BatchTransitionInput,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
type PantryItemResponse = z.infer<typeof PantryItemResponseSchema>;
|
||||
type PantryItemListResponse = z.infer<typeof PantryItemListResponseSchema>;
|
||||
type WasteStatsResponse = z.infer<typeof WasteStatsResponseSchema>;
|
||||
type BatchTransitionResponse = z.infer<typeof BatchTransitionResponseSchema>;
|
||||
|
||||
export interface PantryQuery {
|
||||
storageLocation?: string;
|
||||
status?: string;
|
||||
urgency?: string;
|
||||
productId?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function listPantryItems(
|
||||
householdId: string,
|
||||
query?: PantryQuery,
|
||||
): Promise<PantryItemListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.storageLocation) params.set('storageLocation', query.storageLocation);
|
||||
if (query?.status) params.set('status', query.status);
|
||||
if (query?.urgency) params.set('urgency', query.urgency);
|
||||
if (query?.productId) params.set('productId', query.productId);
|
||||
if (query?.cursor) params.set('cursor', query.cursor);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
return apiClient.get<PantryItemListResponse>(
|
||||
`/households/${householdId}/pantry${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPantryItem(householdId: string, id: string): Promise<PantryItemResponse> {
|
||||
return apiClient.get<PantryItemResponse>(`/households/${householdId}/pantry/${id}`);
|
||||
}
|
||||
|
||||
export async function createPantryItem(
|
||||
householdId: string,
|
||||
data: CreatePantryItemInput,
|
||||
): Promise<PantryItemResponse> {
|
||||
return apiClient.post<PantryItemResponse>(`/households/${householdId}/pantry`, data);
|
||||
}
|
||||
|
||||
export async function updatePantryItem(
|
||||
householdId: string,
|
||||
id: string,
|
||||
data: UpdatePantryItemInput,
|
||||
): Promise<PantryItemResponse> {
|
||||
return apiClient.patch<PantryItemResponse>(`/households/${householdId}/pantry/${id}`, data);
|
||||
}
|
||||
|
||||
export async function transitionPantryItem(
|
||||
householdId: string,
|
||||
id: string,
|
||||
data: TransitionPantryItemInput,
|
||||
): Promise<PantryItemResponse> {
|
||||
return apiClient.post<PantryItemResponse>(
|
||||
`/households/${householdId}/pantry/${id}/transition`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export async function batchTransitionPantryItems(
|
||||
householdId: string,
|
||||
data: BatchTransitionInput,
|
||||
): Promise<BatchTransitionResponse> {
|
||||
return apiClient.post<BatchTransitionResponse>(
|
||||
`/households/${householdId}/pantry/batch-transition`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getExpiringSoon(
|
||||
householdId: string,
|
||||
query?: { days?: number; cursor?: string; limit?: number },
|
||||
): Promise<PantryItemListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.days !== undefined) params.set('days', String(query.days));
|
||||
if (query?.cursor) params.set('cursor', query.cursor);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
return apiClient.get<PantryItemListResponse>(
|
||||
`/households/${householdId}/pantry/expiring-soon${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getWasteStats(
|
||||
householdId: string,
|
||||
period?: string,
|
||||
): Promise<WasteStatsResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (period) params.set('period', period);
|
||||
const qs = params.toString();
|
||||
return apiClient.get<WasteStatsResponse>(
|
||||
`/households/${householdId}/pantry/stats${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deletePantryItem(householdId: string, id: string): Promise<void> {
|
||||
return apiClient.delete(`/households/${householdId}/pantry/${id}`);
|
||||
}
|
||||
107
packages/web/src/services/products.ts
Normal file
107
packages/web/src/services/products.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { apiClient } from './api-client';
|
||||
import type { z } from 'zod/v4';
|
||||
import type {
|
||||
ProductResponseSchema,
|
||||
ProductListResponseSchema,
|
||||
CreateProductInput,
|
||||
UpdateProductInput,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
type ProductResponse = z.infer<typeof ProductResponseSchema>;
|
||||
type ProductListResponse = z.infer<typeof ProductListResponseSchema>;
|
||||
|
||||
export interface ProductQuery {
|
||||
q?: string;
|
||||
category?: string;
|
||||
tags?: string;
|
||||
barcode?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function listProducts(
|
||||
householdId: string,
|
||||
query?: ProductQuery,
|
||||
): Promise<ProductListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.q) params.set('q', query.q);
|
||||
if (query?.category) params.set('category', query.category);
|
||||
if (query?.tags) params.set('tags', query.tags);
|
||||
if (query?.barcode) params.set('barcode', query.barcode);
|
||||
if (query?.cursor) params.set('cursor', query.cursor);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
return apiClient.get<ProductListResponse>(
|
||||
`/households/${householdId}/products${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getProduct(householdId: string, id: string): Promise<ProductResponse> {
|
||||
return apiClient.get<ProductResponse>(`/households/${householdId}/products/${id}`);
|
||||
}
|
||||
|
||||
export async function lookupBarcode(
|
||||
householdId: string,
|
||||
barcode: string,
|
||||
): Promise<ProductResponse | { found: false }> {
|
||||
return apiClient.get<ProductResponse | { found: false }>(
|
||||
`/households/${householdId}/products/barcode/${barcode}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createProduct(
|
||||
householdId: string,
|
||||
data: CreateProductInput,
|
||||
): Promise<ProductResponse> {
|
||||
return apiClient.post<ProductResponse>(`/households/${householdId}/products`, data);
|
||||
}
|
||||
|
||||
export async function updateProduct(
|
||||
householdId: string,
|
||||
id: string,
|
||||
data: UpdateProductInput,
|
||||
): Promise<ProductResponse> {
|
||||
return apiClient.patch<ProductResponse>(`/households/${householdId}/products/${id}`, data);
|
||||
}
|
||||
|
||||
export async function deleteProduct(householdId: string, id: string): Promise<void> {
|
||||
return apiClient.delete(`/households/${householdId}/products/${id}`);
|
||||
}
|
||||
|
||||
export async function smartAddProduct(
|
||||
householdId: string,
|
||||
text: string,
|
||||
): Promise<{ available: false; message: string }> {
|
||||
return apiClient.post<{ available: false; message: string }>(
|
||||
`/households/${householdId}/products/smart-add`,
|
||||
{ text },
|
||||
);
|
||||
}
|
||||
|
||||
export async function importProducts(
|
||||
householdId: string,
|
||||
file: File,
|
||||
): Promise<{
|
||||
imported: number;
|
||||
skippedDuplicates: number;
|
||||
errors: { row: number; message: string }[];
|
||||
}> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
|
||||
const res = await fetch(`${baseUrl}/households/${householdId}/products/import`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message: string;
|
||||
try {
|
||||
const body = await res.json();
|
||||
message = body.message || `Import failed: ${res.status}`;
|
||||
} catch {
|
||||
message = `Import failed: ${res.status} ${res.statusText}`;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
101
packages/web/src/services/recipes.ts
Normal file
101
packages/web/src/services/recipes.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { apiClient } from './api-client';
|
||||
import type { z } from 'zod/v4';
|
||||
import type {
|
||||
RecipeResponseSchema,
|
||||
RecipeListResponseSchema,
|
||||
CreateRecipeInput,
|
||||
UpdateRecipeInput,
|
||||
ScaleRecipeInput,
|
||||
ImportRecipeTextInput,
|
||||
ImportRecipeUrlInput,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
type RecipeResponse = z.infer<typeof RecipeResponseSchema>;
|
||||
type RecipeListResponse = z.infer<typeof RecipeListResponseSchema>;
|
||||
|
||||
export interface RecipeQuery {
|
||||
q?: string;
|
||||
tags?: string;
|
||||
cuisine?: string;
|
||||
maxCalories?: number;
|
||||
isFavorite?: boolean;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function listRecipes(
|
||||
householdId: string,
|
||||
query?: RecipeQuery,
|
||||
): Promise<RecipeListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.q) params.set('q', query.q);
|
||||
if (query?.tags) params.set('tags', query.tags);
|
||||
if (query?.cuisine) params.set('cuisine', query.cuisine);
|
||||
if (query?.maxCalories !== undefined) params.set('maxCalories', String(query.maxCalories));
|
||||
if (query?.isFavorite !== undefined) params.set('isFavorite', String(query.isFavorite));
|
||||
if (query?.cursor) params.set('cursor', query.cursor);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
return apiClient.get<RecipeListResponse>(
|
||||
`/households/${householdId}/recipes${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getRecipe(householdId: string, id: string): Promise<RecipeResponse> {
|
||||
return apiClient.get<RecipeResponse>(`/households/${householdId}/recipes/${id}`);
|
||||
}
|
||||
|
||||
export async function createRecipe(
|
||||
householdId: string,
|
||||
data: CreateRecipeInput,
|
||||
): Promise<RecipeResponse> {
|
||||
return apiClient.post<RecipeResponse>(`/households/${householdId}/recipes`, data);
|
||||
}
|
||||
|
||||
export async function updateRecipe(
|
||||
householdId: string,
|
||||
id: string,
|
||||
data: UpdateRecipeInput,
|
||||
): Promise<RecipeResponse> {
|
||||
return apiClient.patch<RecipeResponse>(`/households/${householdId}/recipes/${id}`, data);
|
||||
}
|
||||
|
||||
export async function deleteRecipe(householdId: string, id: string): Promise<void> {
|
||||
return apiClient.delete(`/households/${householdId}/recipes/${id}`);
|
||||
}
|
||||
|
||||
export async function scaleRecipe(
|
||||
householdId: string,
|
||||
id: string,
|
||||
data: ScaleRecipeInput,
|
||||
): Promise<RecipeResponse> {
|
||||
return apiClient.post<RecipeResponse>(`/households/${householdId}/recipes/${id}/scale`, data);
|
||||
}
|
||||
|
||||
export async function importRecipeFromText(
|
||||
householdId: string,
|
||||
data: ImportRecipeTextInput,
|
||||
): Promise<{ available: boolean; draft?: unknown }> {
|
||||
return apiClient.post(`/households/${householdId}/recipes/import-text`, data);
|
||||
}
|
||||
|
||||
export async function importRecipeFromUrl(
|
||||
householdId: string,
|
||||
data: ImportRecipeUrlInput,
|
||||
): Promise<{ available: boolean; draft?: unknown }> {
|
||||
return apiClient.post(`/households/${householdId}/recipes/import-url`, data);
|
||||
}
|
||||
|
||||
export async function listRecipesByProduct(
|
||||
householdId: string,
|
||||
productId: string,
|
||||
query?: { cursor?: string; limit?: number },
|
||||
): Promise<RecipeListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.cursor) params.set('cursor', query.cursor);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
return apiClient.get<RecipeListResponse>(
|
||||
`/households/${householdId}/recipes/by-product/${productId}${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue