feat: add cookbook nesting and auto-filtering capabilities
Enables cookbooks to include other cookbooks and automatically organize content based on tags. This allows users to create hierarchical cookbook structures and maintain collections that automatically update as new content is added. Key features: - Cookbook nesting: Include child cookbooks within parent cookbooks - Auto-filtering by cookbook tags: Automatically include cookbooks matching specified tags - Auto-filtering by recipe tags: Automatically add recipes matching specified tags - Enhanced cookbook management UI with tag support - Comprehensive test coverage for new functionality Database schema updated with CookbookInclusion and CookbookTag tables. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ model User {
|
||||
sharedRecipes RecipeShare[]
|
||||
refreshTokens RefreshToken[]
|
||||
verificationTokens VerificationToken[]
|
||||
mealPlans MealPlan[]
|
||||
|
||||
@@index([email])
|
||||
@@index([provider, providerId])
|
||||
@@ -103,6 +104,7 @@ model Recipe {
|
||||
tags RecipeTag[]
|
||||
cookbooks CookbookRecipe[]
|
||||
sharedWith RecipeShare[]
|
||||
meals MealRecipe[]
|
||||
|
||||
@@index([title])
|
||||
@@index([cuisine])
|
||||
@@ -185,9 +187,10 @@ model RecipeImage {
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
recipes RecipeTag[]
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
recipes RecipeTag[]
|
||||
cookbooks CookbookTag[]
|
||||
}
|
||||
|
||||
model RecipeTag {
|
||||
@@ -202,6 +205,18 @@ model RecipeTag {
|
||||
@@index([tagId])
|
||||
}
|
||||
|
||||
model CookbookTag {
|
||||
cookbookId String
|
||||
tagId String
|
||||
|
||||
cookbook Cookbook @relation(fields: [cookbookId], references: [id], onDelete: Cascade)
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([cookbookId, tagId])
|
||||
@@index([cookbookId])
|
||||
@@index([tagId])
|
||||
}
|
||||
|
||||
model RecipeShare {
|
||||
id String @id @default(cuid())
|
||||
recipeId String
|
||||
@@ -217,18 +232,22 @@ model RecipeShare {
|
||||
}
|
||||
|
||||
model Cookbook {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
coverImageUrl String?
|
||||
userId String? // Cookbook owner
|
||||
autoFilterCategories String[] @default([]) // Auto-add recipes matching these categories
|
||||
autoFilterTags String[] @default([]) // Auto-add recipes matching these tags
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
coverImageUrl String?
|
||||
userId String? // Cookbook owner
|
||||
autoFilterCategories String[] @default([]) // Auto-add recipes matching these categories
|
||||
autoFilterTags String[] @default([]) // Auto-add recipes matching these tags
|
||||
autoFilterCookbookTags String[] @default([]) // Auto-add cookbooks matching these tags
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
recipes CookbookRecipe[]
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
recipes CookbookRecipe[]
|
||||
tags CookbookTag[]
|
||||
includedCookbooks CookbookInclusion[] @relation("ParentCookbook")
|
||||
includedIn CookbookInclusion[] @relation("ChildCookbook")
|
||||
|
||||
@@index([name])
|
||||
@@index([userId])
|
||||
@@ -247,3 +266,70 @@ model CookbookRecipe {
|
||||
@@index([cookbookId])
|
||||
@@index([recipeId])
|
||||
}
|
||||
|
||||
model CookbookInclusion {
|
||||
id String @id @default(cuid())
|
||||
parentCookbookId String
|
||||
childCookbookId String
|
||||
addedAt DateTime @default(now())
|
||||
|
||||
parentCookbook Cookbook @relation("ParentCookbook", fields: [parentCookbookId], references: [id], onDelete: Cascade)
|
||||
childCookbook Cookbook @relation("ChildCookbook", fields: [childCookbookId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([parentCookbookId, childCookbookId])
|
||||
@@index([parentCookbookId])
|
||||
@@index([childCookbookId])
|
||||
}
|
||||
|
||||
model MealPlan {
|
||||
id String @id @default(cuid())
|
||||
userId String?
|
||||
date DateTime // The day this meal plan is for (stored at midnight UTC)
|
||||
notes String? @db.Text // Optional notes for the entire day
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
meals Meal[]
|
||||
|
||||
@@unique([userId, date]) // One meal plan per user per day
|
||||
@@index([userId])
|
||||
@@index([date])
|
||||
@@index([userId, date])
|
||||
}
|
||||
|
||||
model Meal {
|
||||
id String @id @default(cuid())
|
||||
mealPlanId String
|
||||
mealType MealType
|
||||
order Int // Order within the same meal type (for multi-recipe meals)
|
||||
servings Int? // Servings for this specific meal (can override recipe default)
|
||||
notes String? @db.Text // Meal-specific notes
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
mealPlan MealPlan @relation(fields: [mealPlanId], references: [id], onDelete: Cascade)
|
||||
recipe MealRecipe?
|
||||
|
||||
@@index([mealPlanId])
|
||||
@@index([mealType])
|
||||
}
|
||||
|
||||
model MealRecipe {
|
||||
mealId String @id
|
||||
recipeId String
|
||||
|
||||
meal Meal @relation(fields: [mealId], references: [id], onDelete: Cascade)
|
||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([recipeId])
|
||||
}
|
||||
|
||||
enum MealType {
|
||||
BREAKFAST
|
||||
LUNCH
|
||||
DINNER
|
||||
SNACK
|
||||
DESSERT
|
||||
OTHER
|
||||
}
|
||||
|
||||
@@ -84,6 +84,9 @@ describe('Cookbook & Tags - Integration Tests', () => {
|
||||
// Step 3: Retrieve the cookbook with its recipes
|
||||
const cookbookWithRecipes = {
|
||||
...createdCookbook,
|
||||
autoFilterCategories: [],
|
||||
autoFilterTags: [],
|
||||
autoFilterCookbookTags: [],
|
||||
recipes: [
|
||||
{
|
||||
recipe: {
|
||||
@@ -98,6 +101,8 @@ describe('Cookbook & Tags - Integration Tests', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
includedCookbooks: [],
|
||||
tags: [],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.default.cookbook.findUnique).mockResolvedValue(cookbookWithRecipes as any);
|
||||
@@ -262,6 +267,9 @@ describe('Cookbook & Tags - Integration Tests', () => {
|
||||
name: 'Weeknight Dinners',
|
||||
description: 'Quick and healthy meals',
|
||||
coverImageUrl: null,
|
||||
autoFilterCategories: [],
|
||||
autoFilterTags: [],
|
||||
autoFilterCookbookTags: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
recipes: [
|
||||
@@ -277,6 +285,8 @@ describe('Cookbook & Tags - Integration Tests', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
includedCookbooks: [],
|
||||
tags: [],
|
||||
} as any);
|
||||
|
||||
const response = await request(app).get('/cookbooks/cb1').expect(200);
|
||||
|
||||
@@ -22,6 +22,16 @@ vi.mock('../config/database', () => ({
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
cookbookTag: {
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
cookbookInclusion: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
recipe: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
@@ -61,7 +71,9 @@ describe('Cookbooks Routes - Real Integration Tests', () => {
|
||||
coverImageUrl: '/uploads/italian.jpg',
|
||||
autoFilterCategories: [],
|
||||
autoFilterTags: [],
|
||||
_count: { recipes: 10 },
|
||||
autoFilterCookbookTags: [],
|
||||
tags: [],
|
||||
_count: { recipes: 10, includedCookbooks: 0 },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
@@ -72,12 +84,15 @@ describe('Cookbooks Routes - Real Integration Tests', () => {
|
||||
coverImageUrl: null,
|
||||
autoFilterCategories: [],
|
||||
autoFilterTags: ['quick'],
|
||||
_count: { recipes: 5 },
|
||||
autoFilterCookbookTags: [],
|
||||
tags: [],
|
||||
_count: { recipes: 5, includedCookbooks: 0 },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
(prisma.cookbookInclusion.findMany as any).mockResolvedValue([]);
|
||||
(prisma.cookbook.findMany as any).mockResolvedValue(mockCookbooks);
|
||||
|
||||
const response = await request(app).get('/api/cookbooks');
|
||||
@@ -90,6 +105,7 @@ describe('Cookbooks Routes - Real Integration Tests', () => {
|
||||
});
|
||||
|
||||
it('should return empty array when no cookbooks exist', async () => {
|
||||
(prisma.cookbookInclusion.findMany as any).mockResolvedValue([]);
|
||||
(prisma.cookbook.findMany as any).mockResolvedValue([]);
|
||||
|
||||
const response = await request(app).get('/api/cookbooks');
|
||||
@@ -99,6 +115,7 @@ describe('Cookbooks Routes - Real Integration Tests', () => {
|
||||
});
|
||||
|
||||
it('should return 500 on database error', async () => {
|
||||
(prisma.cookbookInclusion.findMany as any).mockResolvedValue([]);
|
||||
(prisma.cookbook.findMany as any).mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app).get('/api/cookbooks');
|
||||
@@ -117,6 +134,8 @@ describe('Cookbooks Routes - Real Integration Tests', () => {
|
||||
coverImageUrl: '/uploads/italian.jpg',
|
||||
autoFilterCategories: ['Italian'],
|
||||
autoFilterTags: [],
|
||||
autoFilterCookbookTags: [],
|
||||
tags: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
recipes: [
|
||||
@@ -129,6 +148,7 @@ describe('Cookbooks Routes - Real Integration Tests', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
includedCookbooks: [],
|
||||
};
|
||||
|
||||
(prisma.cookbook.findUnique as any).mockResolvedValue(mockCookbook);
|
||||
@@ -180,6 +200,14 @@ describe('Cookbooks Routes - Real Integration Tests', () => {
|
||||
|
||||
(prisma.cookbook.create as any).mockResolvedValue(mockCreatedCookbook);
|
||||
(prisma.recipe.findMany as any).mockResolvedValue([]);
|
||||
(prisma.cookbook.findMany as any).mockResolvedValue([]);
|
||||
(prisma.cookbook.findUnique as any).mockResolvedValue({
|
||||
id: '1',
|
||||
autoFilterTags: ['vegetarian'],
|
||||
autoFilterCategories: [],
|
||||
autoFilterCookbookTags: [],
|
||||
tags: [],
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/cookbooks')
|
||||
@@ -219,11 +247,14 @@ describe('Cookbooks Routes - Real Integration Tests', () => {
|
||||
id: '1',
|
||||
autoFilterTags: ['quick'],
|
||||
autoFilterCategories: [],
|
||||
autoFilterCookbookTags: [],
|
||||
tags: [],
|
||||
});
|
||||
(prisma.recipe.findMany as any).mockResolvedValue([
|
||||
{ id: 'recipe-1' },
|
||||
{ id: 'recipe-2' },
|
||||
]);
|
||||
(prisma.cookbook.findMany as any).mockResolvedValue([]);
|
||||
(prisma.cookbookRecipe.create as any).mockResolvedValue({});
|
||||
|
||||
const response = await request(app)
|
||||
@@ -290,8 +321,11 @@ describe('Cookbooks Routes - Real Integration Tests', () => {
|
||||
id: '1',
|
||||
autoFilterTags: ['vegetarian'],
|
||||
autoFilterCategories: [],
|
||||
autoFilterCookbookTags: [],
|
||||
tags: [],
|
||||
});
|
||||
(prisma.recipe.findMany as any).mockResolvedValue([]);
|
||||
(prisma.cookbook.findMany as any).mockResolvedValue([]);
|
||||
|
||||
const response = await request(app)
|
||||
.put('/api/cookbooks/1')
|
||||
|
||||
@@ -18,6 +18,19 @@ vi.mock('../config/database', () => ({
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
cookbookTag: {
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
cookbookInclusion: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
recipe: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -43,44 +56,64 @@ describe('Cookbooks Routes - Unit Tests', () => {
|
||||
name: 'Family Favorites',
|
||||
description: 'Our favorite family recipes',
|
||||
coverImageUrl: null,
|
||||
autoFilterCategories: [],
|
||||
autoFilterTags: [],
|
||||
autoFilterCookbookTags: [],
|
||||
createdAt: new Date('2025-01-01'),
|
||||
updatedAt: new Date('2025-01-01'),
|
||||
_count: { recipes: 5 },
|
||||
_count: { recipes: 5, includedCookbooks: 0 },
|
||||
tags: [],
|
||||
},
|
||||
{
|
||||
id: 'cb2',
|
||||
name: 'Holiday Recipes',
|
||||
description: 'Recipes for holidays',
|
||||
coverImageUrl: '/uploads/holiday.jpg',
|
||||
autoFilterCategories: [],
|
||||
autoFilterTags: [],
|
||||
autoFilterCookbookTags: [],
|
||||
createdAt: new Date('2025-01-02'),
|
||||
updatedAt: new Date('2025-01-02'),
|
||||
_count: { recipes: 3 },
|
||||
_count: { recipes: 3, includedCookbooks: 0 },
|
||||
tags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbookInclusion.findMany).mockResolvedValue([] as any);
|
||||
vi.mocked(prisma.default.cookbook.findMany).mockResolvedValue(mockCookbooks as any);
|
||||
|
||||
const response = await request(app).get('/cookbooks').expect(200);
|
||||
|
||||
expect(response.body.data).toHaveLength(2);
|
||||
expect(response.body.data[0]).toEqual({
|
||||
expect(response.body.data[0]).toMatchObject({
|
||||
id: 'cb1',
|
||||
name: 'Family Favorites',
|
||||
description: 'Our favorite family recipes',
|
||||
coverImageUrl: null,
|
||||
recipeCount: 5,
|
||||
createdAt: mockCookbooks[0].createdAt.toISOString(),
|
||||
updatedAt: mockCookbooks[0].updatedAt.toISOString(),
|
||||
});
|
||||
expect(prisma.default.cookbook.findMany).toHaveBeenCalledWith({
|
||||
include: {
|
||||
_count: {
|
||||
select: { recipes: true },
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
cookbookCount: 0,
|
||||
autoFilterCategories: [],
|
||||
autoFilterTags: [],
|
||||
autoFilterCookbookTags: [],
|
||||
tags: [],
|
||||
});
|
||||
expect(prisma.default.cookbook.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
include: expect.objectContaining({
|
||||
_count: {
|
||||
select: {
|
||||
recipes: true,
|
||||
includedCookbooks: true
|
||||
}
|
||||
},
|
||||
tags: {
|
||||
include: { tag: true }
|
||||
}
|
||||
}),
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
@@ -100,6 +133,9 @@ describe('Cookbooks Routes - Unit Tests', () => {
|
||||
name: 'Family Favorites',
|
||||
description: 'Our favorite family recipes',
|
||||
coverImageUrl: null,
|
||||
autoFilterCategories: [],
|
||||
autoFilterTags: [],
|
||||
autoFilterCookbookTags: [],
|
||||
createdAt: new Date('2025-01-01'),
|
||||
updatedAt: new Date('2025-01-01'),
|
||||
recipes: [
|
||||
@@ -116,6 +152,8 @@ describe('Cookbooks Routes - Unit Tests', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
includedCookbooks: [],
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
@@ -149,24 +187,36 @@ describe('Cookbooks Routes - Unit Tests', () => {
|
||||
const createdCookbook = {
|
||||
id: 'cb-new',
|
||||
...newCookbook,
|
||||
autoFilterCategories: [],
|
||||
autoFilterTags: [],
|
||||
autoFilterCookbookTags: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbook.create).mockResolvedValue(createdCookbook as any);
|
||||
vi.mocked(prisma.default.recipe.findMany).mockResolvedValue([] as any);
|
||||
vi.mocked(prisma.default.cookbook.findMany).mockResolvedValue([] as any);
|
||||
vi.mocked(prisma.default.cookbook.findUnique).mockResolvedValue({ tags: [] } as any);
|
||||
|
||||
const response = await request(app).post('/cookbooks').send(newCookbook).expect(201);
|
||||
|
||||
expect(response.body.data.id).toBe('cb-new');
|
||||
expect(response.body.data.name).toBe('Quick Meals');
|
||||
expect(prisma.default.cookbook.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
...newCookbook,
|
||||
autoFilterCategories: [],
|
||||
autoFilterTags: [],
|
||||
},
|
||||
});
|
||||
expect(prisma.default.cookbook.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
name: 'Quick Meals',
|
||||
description: 'Fast recipes for busy weeknights',
|
||||
coverImageUrl: '/uploads/quick-meals.jpg',
|
||||
autoFilterCategories: [],
|
||||
autoFilterTags: [],
|
||||
autoFilterCookbookTags: [],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 400 if name is missing', async () => {
|
||||
@@ -192,6 +242,7 @@ describe('Cookbooks Routes - Unit Tests', () => {
|
||||
coverImageUrl: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
@@ -203,6 +254,7 @@ describe('Cookbooks Routes - Unit Tests', () => {
|
||||
expect(prisma.default.cookbook.update).toHaveBeenCalledWith({
|
||||
where: { id: 'cb1' },
|
||||
data: updates,
|
||||
include: { tags: { include: { tag: true } } }
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -271,4 +323,279 @@ describe('Cookbooks Routes - Unit Tests', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /cookbooks - with child exclusion', () => {
|
||||
it('should exclude child cookbooks by default', async () => {
|
||||
const childCookbookIds = [{ childCookbookId: 'cb2' }];
|
||||
const mockCookbooks = [
|
||||
{
|
||||
id: 'cb1',
|
||||
name: 'Parent Cookbook',
|
||||
description: 'A parent cookbook',
|
||||
coverImageUrl: null,
|
||||
autoFilterCategories: [],
|
||||
autoFilterTags: [],
|
||||
autoFilterCookbookTags: [],
|
||||
tags: [],
|
||||
_count: { recipes: 5, includedCookbooks: 1 },
|
||||
createdAt: new Date('2025-01-01'),
|
||||
updatedAt: new Date('2025-01-01'),
|
||||
},
|
||||
];
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbookInclusion.findMany).mockResolvedValue(childCookbookIds as any);
|
||||
vi.mocked(prisma.default.cookbook.findMany).mockResolvedValue(mockCookbooks as any);
|
||||
|
||||
const response = await request(app).get('/cookbooks').expect(200);
|
||||
|
||||
expect(prisma.default.cookbookInclusion.findMany).toHaveBeenCalledWith({
|
||||
select: { childCookbookId: true },
|
||||
distinct: ['childCookbookId'],
|
||||
});
|
||||
expect(prisma.default.cookbook.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: { notIn: ['cb2'] } },
|
||||
})
|
||||
);
|
||||
expect(response.body.data).toHaveLength(1);
|
||||
expect(response.body.data[0].cookbookCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should include child cookbooks when includeChildren=true', async () => {
|
||||
const mockCookbooks = [
|
||||
{
|
||||
id: 'cb1',
|
||||
name: 'Parent Cookbook',
|
||||
description: null,
|
||||
coverImageUrl: null,
|
||||
autoFilterCategories: [],
|
||||
autoFilterTags: [],
|
||||
autoFilterCookbookTags: [],
|
||||
tags: [],
|
||||
_count: { recipes: 5, includedCookbooks: 1 },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'cb2',
|
||||
name: 'Child Cookbook',
|
||||
description: null,
|
||||
coverImageUrl: null,
|
||||
autoFilterCategories: [],
|
||||
autoFilterTags: [],
|
||||
autoFilterCookbookTags: [],
|
||||
tags: [],
|
||||
_count: { recipes: 3, includedCookbooks: 0 },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbookInclusion.findMany).mockResolvedValue([] as any);
|
||||
vi.mocked(prisma.default.cookbook.findMany).mockResolvedValue(mockCookbooks as any);
|
||||
|
||||
const response = await request(app).get('/cookbooks?includeChildren=true').expect(200);
|
||||
|
||||
// When includeChildren=true, cookbookInclusion.findMany should NOT be called
|
||||
expect(prisma.default.cookbookInclusion.findMany).not.toHaveBeenCalled();
|
||||
expect(response.body.data).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /cookbooks/:id - with nested cookbooks', () => {
|
||||
it('should return cookbook with included cookbooks', async () => {
|
||||
const mockCookbook = {
|
||||
id: 'cb1',
|
||||
name: 'Parent Cookbook',
|
||||
description: 'A parent cookbook',
|
||||
coverImageUrl: null,
|
||||
autoFilterCategories: [],
|
||||
autoFilterTags: [],
|
||||
autoFilterCookbookTags: ['holiday'],
|
||||
createdAt: new Date('2025-01-01'),
|
||||
updatedAt: new Date('2025-01-01'),
|
||||
recipes: [],
|
||||
includedCookbooks: [
|
||||
{
|
||||
addedAt: new Date('2025-01-02'),
|
||||
childCookbook: {
|
||||
id: 'cb2',
|
||||
name: 'Child Cookbook',
|
||||
description: 'A child cookbook',
|
||||
coverImageUrl: null,
|
||||
_count: { recipes: 3, includedCookbooks: 0 },
|
||||
tags: [{ tag: { name: 'holiday' } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbook.findUnique).mockResolvedValue(mockCookbook as any);
|
||||
|
||||
const response = await request(app).get('/cookbooks/cb1').expect(200);
|
||||
|
||||
expect(response.body.data.cookbooks).toHaveLength(1);
|
||||
expect(response.body.data.cookbooks[0]).toMatchObject({
|
||||
id: 'cb2',
|
||||
name: 'Child Cookbook',
|
||||
recipeCount: 3,
|
||||
cookbookCount: 0,
|
||||
tags: ['holiday'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /cookbooks - with tags', () => {
|
||||
it('should create cookbook with tags and autoFilterCookbookTags', async () => {
|
||||
const mockCookbook = {
|
||||
id: 'cb1',
|
||||
name: 'Holiday Cookbook',
|
||||
description: 'Holiday recipes',
|
||||
coverImageUrl: null,
|
||||
autoFilterCategories: [],
|
||||
autoFilterTags: [],
|
||||
autoFilterCookbookTags: ['seasonal'],
|
||||
tags: [{ tag: { name: 'holiday' } }],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbook.create).mockResolvedValue(mockCookbook as any);
|
||||
vi.mocked(prisma.default.recipe.findMany).mockResolvedValue([] as any);
|
||||
vi.mocked(prisma.default.cookbook.findMany).mockResolvedValue([] as any);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/cookbooks')
|
||||
.send({
|
||||
name: 'Holiday Cookbook',
|
||||
description: 'Holiday recipes',
|
||||
tags: ['holiday'],
|
||||
autoFilterCookbookTags: ['seasonal'],
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(prisma.default.cookbook.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
name: 'Holiday Cookbook',
|
||||
autoFilterCookbookTags: ['seasonal'],
|
||||
tags: expect.objectContaining({
|
||||
create: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
tag: expect.objectContaining({
|
||||
connectOrCreate: expect.objectContaining({
|
||||
where: { name: 'holiday' },
|
||||
create: { name: 'holiday' },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /cookbooks/:id - with tag updates', () => {
|
||||
it('should update cookbook tags and trigger re-filtering', async () => {
|
||||
const mockCookbook = {
|
||||
id: 'cb1',
|
||||
name: 'Updated Cookbook',
|
||||
tags: [{ tag: { name: 'updated' } }],
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbookTag.deleteMany).mockResolvedValue({ count: 1 } as any);
|
||||
vi.mocked(prisma.default.cookbook.update).mockResolvedValue(mockCookbook as any);
|
||||
vi.mocked(prisma.default.cookbookInclusion.deleteMany).mockResolvedValue({ count: 0 } as any);
|
||||
vi.mocked(prisma.default.cookbook.findUnique).mockResolvedValue({
|
||||
...mockCookbook,
|
||||
autoFilterCookbookTags: [],
|
||||
} as any);
|
||||
vi.mocked(prisma.default.cookbook.findMany).mockResolvedValue([] as any);
|
||||
|
||||
await request(app)
|
||||
.put('/cookbooks/cb1')
|
||||
.send({
|
||||
tags: ['updated'],
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(prisma.default.cookbookTag.deleteMany).toHaveBeenCalledWith({
|
||||
where: { cookbookId: 'cb1' },
|
||||
});
|
||||
expect(prisma.default.cookbookInclusion.deleteMany).toHaveBeenCalledWith({
|
||||
where: { childCookbookId: 'cb1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /cookbooks/:id/cookbooks/:childCookbookId', () => {
|
||||
it('should add a cookbook to another cookbook', async () => {
|
||||
const mockInclusion = {
|
||||
id: 'inc1',
|
||||
parentCookbookId: 'cb1',
|
||||
childCookbookId: 'cb2',
|
||||
addedAt: new Date(),
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbookInclusion.findUnique).mockResolvedValue(null as any);
|
||||
vi.mocked(prisma.default.cookbookInclusion.create).mockResolvedValue(mockInclusion as any);
|
||||
|
||||
const response = await request(app).post('/cookbooks/cb1/cookbooks/cb2').expect(201);
|
||||
|
||||
expect(response.body.data).toMatchObject({
|
||||
parentCookbookId: 'cb1',
|
||||
childCookbookId: 'cb2',
|
||||
});
|
||||
});
|
||||
|
||||
it('should prevent adding cookbook to itself', async () => {
|
||||
const response = await request(app).post('/cookbooks/cb1/cookbooks/cb1').expect(400);
|
||||
|
||||
expect(response.body.error).toBe('Cannot add cookbook to itself');
|
||||
});
|
||||
|
||||
it('should prevent duplicate cookbook inclusions', async () => {
|
||||
const mockExisting = {
|
||||
id: 'inc1',
|
||||
parentCookbookId: 'cb1',
|
||||
childCookbookId: 'cb2',
|
||||
addedAt: new Date(),
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbookInclusion.findUnique).mockResolvedValue(mockExisting as any);
|
||||
|
||||
const response = await request(app).post('/cookbooks/cb1/cookbooks/cb2').expect(400);
|
||||
|
||||
expect(response.body.error).toBe('Cookbook already included');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /cookbooks/:id/cookbooks/:childCookbookId', () => {
|
||||
it('should remove a cookbook from another cookbook', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbookInclusion.delete).mockResolvedValue({} as any);
|
||||
|
||||
const response = await request(app).delete('/cookbooks/cb1/cookbooks/cb2').expect(200);
|
||||
|
||||
expect(response.body.message).toBe('Cookbook removed successfully');
|
||||
expect(prisma.default.cookbookInclusion.delete).toHaveBeenCalledWith({
|
||||
where: {
|
||||
parentCookbookId_childCookbookId: {
|
||||
parentCookbookId: 'cb1',
|
||||
childCookbookId: 'cb2',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,13 +88,143 @@ async function applyFiltersToExistingRecipes(cookbookId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to apply cookbook tag filters to existing cookbooks
|
||||
async function applyFiltersToExistingCookbooks(cookbookId: string) {
|
||||
try {
|
||||
const cookbook = await prisma.cookbook.findUnique({
|
||||
where: { id: cookbookId },
|
||||
include: {
|
||||
tags: {
|
||||
include: { tag: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!cookbook) return;
|
||||
|
||||
const cookbookTags = cookbook.autoFilterCookbookTags || [];
|
||||
if (cookbookTags.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find matching cookbooks (excluding self)
|
||||
const matchingCookbooks = await prisma.cookbook.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{ id: { not: cookbookId } },
|
||||
{
|
||||
tags: {
|
||||
some: {
|
||||
tag: {
|
||||
name: { in: cookbookTags }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
// Add each matching cookbook to the parent cookbook
|
||||
for (const childCookbook of matchingCookbooks) {
|
||||
try {
|
||||
await prisma.cookbookInclusion.create({
|
||||
data: {
|
||||
parentCookbookId: cookbookId,
|
||||
childCookbookId: childCookbook.id
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Ignore unique constraint violations (cookbook already included)
|
||||
if (error.code !== 'P2002') {
|
||||
console.error(`Error adding cookbook ${childCookbook.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Applied cookbook filters to ${cookbook.name}: added ${matchingCookbooks.length} cookbooks`);
|
||||
} catch (error) {
|
||||
console.error('Error in applyFiltersToExistingCookbooks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to auto-add cookbook to parent cookbooks based on its tags
|
||||
async function autoAddToParentCookbooks(cookbookId: string) {
|
||||
try {
|
||||
const cookbook = await prisma.cookbook.findUnique({
|
||||
where: { id: cookbookId },
|
||||
include: {
|
||||
tags: {
|
||||
include: { tag: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!cookbook) return;
|
||||
|
||||
const cookbookTags = cookbook.tags.map(ct => ct.tag.name);
|
||||
if (cookbookTags.length === 0) return;
|
||||
|
||||
// Find parent cookbooks with filters matching this cookbook's tags
|
||||
const parentCookbooks = await prisma.cookbook.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{ id: { not: cookbookId } },
|
||||
{ autoFilterCookbookTags: { hasSome: cookbookTags } }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Add this cookbook to each parent
|
||||
for (const parent of parentCookbooks) {
|
||||
try {
|
||||
await prisma.cookbookInclusion.create({
|
||||
data: {
|
||||
parentCookbookId: parent.id,
|
||||
childCookbookId: cookbookId
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Ignore unique constraint violations
|
||||
if (error.code !== 'P2002') {
|
||||
console.error(`Error auto-adding to parent cookbook ${parent.name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Auto-added ${cookbook.name} to ${parentCookbooks.length} parent cookbooks`);
|
||||
} catch (error) {
|
||||
console.error('Error in autoAddToParentCookbooks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all cookbooks with recipe count
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { includeChildren = 'false' } = req.query;
|
||||
|
||||
// Get child cookbook IDs to exclude from main listing (unless includeChildren is true)
|
||||
const childCookbookIds = includeChildren === 'true' ? [] : (
|
||||
await prisma.cookbookInclusion.findMany({
|
||||
select: { childCookbookId: true },
|
||||
distinct: ['childCookbookId']
|
||||
})
|
||||
).map(ci => ci.childCookbookId);
|
||||
|
||||
const cookbooks = await prisma.cookbook.findMany({
|
||||
where: includeChildren === 'true' ? {} : {
|
||||
id: { notIn: childCookbookIds }
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { recipes: true }
|
||||
select: {
|
||||
recipes: true,
|
||||
includedCookbooks: true
|
||||
}
|
||||
},
|
||||
tags: {
|
||||
include: { tag: true }
|
||||
}
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
@@ -107,7 +237,10 @@ router.get('/', async (req: Request, res: Response) => {
|
||||
coverImageUrl: cookbook.coverImageUrl,
|
||||
autoFilterCategories: cookbook.autoFilterCategories,
|
||||
autoFilterTags: cookbook.autoFilterTags,
|
||||
autoFilterCookbookTags: cookbook.autoFilterCookbookTags,
|
||||
tags: cookbook.tags.map(ct => ct.tag.name),
|
||||
recipeCount: cookbook._count.recipes,
|
||||
cookbookCount: cookbook._count.includedCookbooks,
|
||||
createdAt: cookbook.createdAt,
|
||||
updatedAt: cookbook.updatedAt
|
||||
}));
|
||||
@@ -141,6 +274,23 @@ router.get('/:id', async (req: Request, res: Response) => {
|
||||
}
|
||||
},
|
||||
orderBy: { addedAt: 'desc' }
|
||||
},
|
||||
includedCookbooks: {
|
||||
include: {
|
||||
childCookbook: {
|
||||
include: {
|
||||
_count: {
|
||||
select: { recipes: true, includedCookbooks: true }
|
||||
},
|
||||
tags: {
|
||||
include: { tag: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
tags: {
|
||||
include: { tag: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -156,11 +306,23 @@ router.get('/:id', async (req: Request, res: Response) => {
|
||||
coverImageUrl: cookbook.coverImageUrl,
|
||||
autoFilterCategories: cookbook.autoFilterCategories,
|
||||
autoFilterTags: cookbook.autoFilterTags,
|
||||
autoFilterCookbookTags: cookbook.autoFilterCookbookTags,
|
||||
tags: cookbook.tags.map(ct => ct.tag.name),
|
||||
createdAt: cookbook.createdAt,
|
||||
updatedAt: cookbook.updatedAt,
|
||||
recipes: cookbook.recipes.map(cr => ({
|
||||
...cr.recipe,
|
||||
tags: cr.recipe.tags.map(rt => rt.tag.name)
|
||||
})),
|
||||
cookbooks: cookbook.includedCookbooks.map(ci => ({
|
||||
id: ci.childCookbook.id,
|
||||
name: ci.childCookbook.name,
|
||||
description: ci.childCookbook.description,
|
||||
coverImageUrl: ci.childCookbook.coverImageUrl,
|
||||
tags: ci.childCookbook.tags.map(ct => ct.tag.name),
|
||||
recipeCount: ci.childCookbook._count.recipes,
|
||||
cookbookCount: ci.childCookbook._count.includedCookbooks,
|
||||
addedAt: ci.addedAt
|
||||
}))
|
||||
};
|
||||
|
||||
@@ -174,7 +336,7 @@ router.get('/:id', async (req: Request, res: Response) => {
|
||||
// Create a new cookbook
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name, description, coverImageUrl, autoFilterCategories, autoFilterTags } = req.body;
|
||||
const { name, description, coverImageUrl, autoFilterCategories, autoFilterTags, autoFilterCookbookTags, tags } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Name is required' });
|
||||
@@ -186,13 +348,33 @@ router.post('/', async (req: Request, res: Response) => {
|
||||
description,
|
||||
coverImageUrl,
|
||||
autoFilterCategories: autoFilterCategories || [],
|
||||
autoFilterTags: autoFilterTags || []
|
||||
autoFilterTags: autoFilterTags || [],
|
||||
autoFilterCookbookTags: autoFilterCookbookTags || [],
|
||||
tags: tags ? {
|
||||
create: tags.map((tagName: string) => ({
|
||||
tag: {
|
||||
connectOrCreate: {
|
||||
where: { name: tagName },
|
||||
create: { name: tagName }
|
||||
}
|
||||
}
|
||||
}))
|
||||
} : undefined
|
||||
},
|
||||
include: {
|
||||
tags: { include: { tag: true } }
|
||||
}
|
||||
});
|
||||
|
||||
// Apply filters to existing recipes
|
||||
await applyFiltersToExistingRecipes(cookbook.id);
|
||||
|
||||
// Apply filters to existing cookbooks
|
||||
await applyFiltersToExistingCookbooks(cookbook.id);
|
||||
|
||||
// Auto-add this cookbook to parent cookbooks
|
||||
await autoAddToParentCookbooks(cookbook.id);
|
||||
|
||||
res.status(201).json({ data: cookbook });
|
||||
} catch (error) {
|
||||
console.error('Error creating cookbook:', error);
|
||||
@@ -204,7 +386,7 @@ router.post('/', async (req: Request, res: Response) => {
|
||||
router.put('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description, coverImageUrl, autoFilterCategories, autoFilterTags } = req.body;
|
||||
const { name, description, coverImageUrl, autoFilterCategories, autoFilterTags, autoFilterCookbookTags, tags } = req.body;
|
||||
|
||||
const updateData: any = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
@@ -212,10 +394,36 @@ router.put('/:id', async (req: Request, res: Response) => {
|
||||
if (coverImageUrl !== undefined) updateData.coverImageUrl = coverImageUrl;
|
||||
if (autoFilterCategories !== undefined) updateData.autoFilterCategories = autoFilterCategories;
|
||||
if (autoFilterTags !== undefined) updateData.autoFilterTags = autoFilterTags;
|
||||
if (autoFilterCookbookTags !== undefined) updateData.autoFilterCookbookTags = autoFilterCookbookTags;
|
||||
|
||||
// Handle tags update separately
|
||||
if (tags !== undefined) {
|
||||
// Delete existing tags
|
||||
await prisma.cookbookTag.deleteMany({
|
||||
where: { cookbookId: id }
|
||||
});
|
||||
|
||||
// Create new tags
|
||||
if (tags.length > 0) {
|
||||
updateData.tags = {
|
||||
create: tags.map((tagName: string) => ({
|
||||
tag: {
|
||||
connectOrCreate: {
|
||||
where: { name: tagName },
|
||||
create: { name: tagName }
|
||||
}
|
||||
}
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const cookbook = await prisma.cookbook.update({
|
||||
where: { id },
|
||||
data: updateData
|
||||
data: updateData,
|
||||
include: {
|
||||
tags: { include: { tag: true } }
|
||||
}
|
||||
});
|
||||
|
||||
// Apply filters to existing recipes if filters were updated
|
||||
@@ -223,6 +431,24 @@ router.put('/:id', async (req: Request, res: Response) => {
|
||||
await applyFiltersToExistingRecipes(id);
|
||||
}
|
||||
|
||||
// Apply cookbook filters if updated
|
||||
if (autoFilterCookbookTags !== undefined) {
|
||||
// Clear existing inclusions first
|
||||
await prisma.cookbookInclusion.deleteMany({
|
||||
where: { parentCookbookId: id }
|
||||
});
|
||||
await applyFiltersToExistingCookbooks(id);
|
||||
}
|
||||
|
||||
// Re-apply to parent cookbooks if tags changed
|
||||
if (tags !== undefined) {
|
||||
// Clear existing parent relationships
|
||||
await prisma.cookbookInclusion.deleteMany({
|
||||
where: { childCookbookId: id }
|
||||
});
|
||||
await autoAddToParentCookbooks(id);
|
||||
}
|
||||
|
||||
res.json({ data: cookbook });
|
||||
} catch (error) {
|
||||
console.error('Error updating cookbook:', error);
|
||||
@@ -300,6 +526,65 @@ router.delete('/:id/recipes/:recipeId', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Add a cookbook to another cookbook
|
||||
router.post('/:id/cookbooks/:childCookbookId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id, childCookbookId } = req.params;
|
||||
|
||||
// Prevent adding cookbook to itself
|
||||
if (id === childCookbookId) {
|
||||
return res.status(400).json({ error: 'Cannot add cookbook to itself' });
|
||||
}
|
||||
|
||||
// Check if cookbook is already included
|
||||
const existing = await prisma.cookbookInclusion.findUnique({
|
||||
where: {
|
||||
parentCookbookId_childCookbookId: {
|
||||
parentCookbookId: id,
|
||||
childCookbookId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return res.status(400).json({ error: 'Cookbook already included' });
|
||||
}
|
||||
|
||||
const inclusion = await prisma.cookbookInclusion.create({
|
||||
data: {
|
||||
parentCookbookId: id,
|
||||
childCookbookId
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json({ data: inclusion });
|
||||
} catch (error) {
|
||||
console.error('Error adding cookbook:', error);
|
||||
res.status(500).json({ error: 'Failed to add cookbook' });
|
||||
}
|
||||
});
|
||||
|
||||
// Remove a cookbook from another cookbook
|
||||
router.delete('/:id/cookbooks/:childCookbookId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id, childCookbookId } = req.params;
|
||||
|
||||
await prisma.cookbookInclusion.delete({
|
||||
where: {
|
||||
parentCookbookId_childCookbookId: {
|
||||
parentCookbookId: id,
|
||||
childCookbookId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ message: 'Cookbook removed successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error removing cookbook:', error);
|
||||
res.status(500).json({ error: 'Failed to remove cookbook' });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload cookbook cover image
|
||||
router.post('/:id/image', upload.single('image'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@@ -105,11 +105,111 @@ export interface Cookbook {
|
||||
coverImageUrl?: string;
|
||||
autoFilterCategories?: string[]; // Auto-add recipes matching these categories
|
||||
autoFilterTags?: string[]; // Auto-add recipes matching these tags
|
||||
autoFilterCookbookTags?: string[]; // Auto-add cookbooks matching these tags
|
||||
tags?: string[]; // Denormalized tag names for display
|
||||
recipeCount?: number; // Computed field for display
|
||||
cookbookCount?: number; // Computed field for display - count of included cookbooks
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CookbookWithRecipes extends Cookbook {
|
||||
recipes: Recipe[];
|
||||
cookbooks?: Cookbook[]; // Included cookbooks
|
||||
}
|
||||
|
||||
// Meal Planner Types
|
||||
export enum MealType {
|
||||
BREAKFAST = 'BREAKFAST',
|
||||
LUNCH = 'LUNCH',
|
||||
DINNER = 'DINNER',
|
||||
SNACK = 'SNACK',
|
||||
DESSERT = 'DESSERT',
|
||||
OTHER = 'OTHER'
|
||||
}
|
||||
|
||||
export interface MealPlan {
|
||||
id: string;
|
||||
userId?: string;
|
||||
date: Date | string;
|
||||
notes?: string;
|
||||
meals: Meal[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Meal {
|
||||
id: string;
|
||||
mealPlanId: string;
|
||||
mealType: MealType;
|
||||
order: number;
|
||||
servings?: number;
|
||||
notes?: string;
|
||||
recipe?: MealRecipeWithDetails;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface MealRecipe {
|
||||
mealId: string;
|
||||
recipeId: string;
|
||||
}
|
||||
|
||||
export interface MealRecipeWithDetails extends MealRecipe {
|
||||
recipe: Recipe;
|
||||
}
|
||||
|
||||
export interface CreateMealPlanRequest {
|
||||
date: string;
|
||||
notes?: string;
|
||||
meals?: CreateMealRequest[];
|
||||
}
|
||||
|
||||
export interface CreateMealRequest {
|
||||
mealType: MealType;
|
||||
recipeId: string;
|
||||
servings?: number;
|
||||
notes?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateMealPlanRequest {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateMealRequest {
|
||||
mealType?: MealType;
|
||||
servings?: number;
|
||||
notes?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface MealPlanQueryParams {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
export interface ShoppingListItem {
|
||||
ingredientName: string;
|
||||
totalAmount: number;
|
||||
unit: string;
|
||||
recipes: string[];
|
||||
}
|
||||
|
||||
export interface ShoppingListRequest {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
export interface ShoppingListResponse {
|
||||
items: ShoppingListItem[];
|
||||
dateRange: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
recipeCount: number;
|
||||
}
|
||||
|
||||
export interface MealPlanResponse extends ApiResponse<MealPlan> {}
|
||||
export interface MealPlansResponse extends ApiResponse<MealPlan[]> {}
|
||||
export interface ShoppingListApiResponse extends ApiResponse<ShoppingListResponse> {}
|
||||
|
||||
@@ -260,8 +260,50 @@ function CookbookDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Included Cookbooks */}
|
||||
{cookbook.cookbooks && cookbook.cookbooks.length > 0 && (
|
||||
<section className="included-cookbooks-section">
|
||||
<h2>Included Cookbooks ({cookbook.cookbooks.length})</h2>
|
||||
<div className="cookbooks-grid">
|
||||
{cookbook.cookbooks.map((childCookbook) => (
|
||||
<div
|
||||
key={childCookbook.id}
|
||||
className="cookbook-card nested"
|
||||
onClick={() => navigate(`/cookbooks/${childCookbook.id}`)}
|
||||
>
|
||||
{childCookbook.coverImageUrl ? (
|
||||
<img src={childCookbook.coverImageUrl} alt={childCookbook.name} className="cookbook-cover" />
|
||||
) : (
|
||||
<div className="cookbook-cover-placeholder">
|
||||
<span>📚</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="cookbook-info">
|
||||
<h3>{childCookbook.name}</h3>
|
||||
{childCookbook.description && <p className="description">{childCookbook.description}</p>}
|
||||
<div className="cookbook-stats">
|
||||
<p className="recipe-count">{childCookbook.recipeCount || 0} recipes</p>
|
||||
{childCookbook.cookbookCount && childCookbook.cookbookCount > 0 && (
|
||||
<p className="cookbook-count">{childCookbook.cookbookCount} cookbooks</p>
|
||||
)}
|
||||
</div>
|
||||
{childCookbook.tags && childCookbook.tags.length > 0 && (
|
||||
<div className="cookbook-tags">
|
||||
{childCookbook.tags.map(tag => (
|
||||
<span key={tag} className="tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
<div className="results-section">
|
||||
<h2>Recipes</h2>
|
||||
<p className="results-count">
|
||||
Showing {filteredRecipes.length} of {cookbook.recipes.length} recipes
|
||||
</p>
|
||||
|
||||
@@ -15,8 +15,12 @@ function Cookbooks() {
|
||||
const [newCookbookDescription, setNewCookbookDescription] = useState('');
|
||||
const [autoFilterCategories, setAutoFilterCategories] = useState<string[]>([]);
|
||||
const [autoFilterTags, setAutoFilterTags] = useState<string[]>([]);
|
||||
const [autoFilterCookbookTags, setAutoFilterCookbookTags] = useState<string[]>([]);
|
||||
const [cookbookTags, setCookbookTags] = useState<string[]>([]);
|
||||
const [categoryInput, setCategoryInput] = useState('');
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [cookbookTagInput, setCookbookTagInput] = useState('');
|
||||
const [cookbookTagFilterInput, setCookbookTagFilterInput] = useState('');
|
||||
const [availableTags, setAvailableTags] = useState<Tag[]>([]);
|
||||
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
|
||||
|
||||
@@ -68,15 +72,21 @@ function Cookbooks() {
|
||||
name: newCookbookName,
|
||||
description: newCookbookDescription || undefined,
|
||||
autoFilterCategories: autoFilterCategories.length > 0 ? autoFilterCategories : undefined,
|
||||
autoFilterTags: autoFilterTags.length > 0 ? autoFilterTags : undefined
|
||||
autoFilterTags: autoFilterTags.length > 0 ? autoFilterTags : undefined,
|
||||
autoFilterCookbookTags: autoFilterCookbookTags.length > 0 ? autoFilterCookbookTags : undefined,
|
||||
tags: cookbookTags.length > 0 ? cookbookTags : undefined
|
||||
});
|
||||
|
||||
setNewCookbookName('');
|
||||
setNewCookbookDescription('');
|
||||
setAutoFilterCategories([]);
|
||||
setAutoFilterTags([]);
|
||||
setAutoFilterCookbookTags([]);
|
||||
setCookbookTags([]);
|
||||
setCategoryInput('');
|
||||
setTagInput('');
|
||||
setCookbookTagInput('');
|
||||
setCookbookTagFilterInput('');
|
||||
setShowCreateModal(false);
|
||||
loadData(); // Reload cookbooks
|
||||
} catch (err) {
|
||||
@@ -109,6 +119,30 @@ function Cookbooks() {
|
||||
setAutoFilterTags(autoFilterTags.filter(t => t !== tag));
|
||||
};
|
||||
|
||||
const handleAddCookbookTag = () => {
|
||||
const trimmed = cookbookTagInput.trim();
|
||||
if (trimmed && !cookbookTags.includes(trimmed)) {
|
||||
setCookbookTags([...cookbookTags, trimmed]);
|
||||
setCookbookTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCookbookTag = (tag: string) => {
|
||||
setCookbookTags(cookbookTags.filter(t => t !== tag));
|
||||
};
|
||||
|
||||
const handleAddCookbookTagFilter = () => {
|
||||
const trimmed = cookbookTagFilterInput.trim();
|
||||
if (trimmed && !autoFilterCookbookTags.includes(trimmed)) {
|
||||
setAutoFilterCookbookTags([...autoFilterCookbookTags, trimmed]);
|
||||
setCookbookTagFilterInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCookbookTagFilter = (tag: string) => {
|
||||
setAutoFilterCookbookTags(autoFilterCookbookTags.filter(t => t !== tag));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="cookbooks-page">
|
||||
@@ -170,7 +204,19 @@ function Cookbooks() {
|
||||
<div className="cookbook-info">
|
||||
<h3>{cookbook.name}</h3>
|
||||
{cookbook.description && <p className="description">{cookbook.description}</p>}
|
||||
<p className="recipe-count">{cookbook.recipeCount || 0} recipes</p>
|
||||
<div className="cookbook-stats">
|
||||
<p className="recipe-count">{cookbook.recipeCount || 0} recipes</p>
|
||||
{cookbook.cookbookCount && cookbook.cookbookCount > 0 && (
|
||||
<p className="cookbook-count">{cookbook.cookbookCount} cookbooks</p>
|
||||
)}
|
||||
</div>
|
||||
{cookbook.tags && cookbook.tags.length > 0 && (
|
||||
<div className="cookbook-tags">
|
||||
{cookbook.tags.map(tag => (
|
||||
<span key={tag} className="tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -305,6 +351,64 @@ function Cookbooks() {
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Cookbook Tags (Optional)</label>
|
||||
<p className="help-text">Tags to categorize this cookbook (e.g., "holiday", "quick-meals")</p>
|
||||
<div className="filter-chips">
|
||||
{cookbookTags.map(tag => (
|
||||
<span key={tag} className="filter-chip">
|
||||
{tag}
|
||||
<button type="button" onClick={() => handleRemoveCookbookTag(tag)}>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="input-with-button">
|
||||
<input
|
||||
type="text"
|
||||
value={cookbookTagInput}
|
||||
onChange={(e) => setCookbookTagInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddCookbookTag())}
|
||||
placeholder="Add tag"
|
||||
list="available-cookbook-tags"
|
||||
/>
|
||||
<button type="button" onClick={handleAddCookbookTag} className="btn-add-filter">+</button>
|
||||
</div>
|
||||
<datalist id="available-cookbook-tags">
|
||||
{availableTags.map(tag => (
|
||||
<option key={tag.id} value={tag.name} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Auto-Include Cookbooks by Tags (Optional)</label>
|
||||
<p className="help-text">Other cookbooks with these tags will be automatically included</p>
|
||||
<div className="filter-chips">
|
||||
{autoFilterCookbookTags.map(tag => (
|
||||
<span key={tag} className="filter-chip">
|
||||
{tag}
|
||||
<button type="button" onClick={() => handleRemoveCookbookTagFilter(tag)}>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="input-with-button">
|
||||
<input
|
||||
type="text"
|
||||
value={cookbookTagFilterInput}
|
||||
onChange={(e) => setCookbookTagFilterInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddCookbookTagFilter())}
|
||||
placeholder="Add tag to filter by"
|
||||
list="available-cookbook-filter-tags"
|
||||
/>
|
||||
<button type="button" onClick={handleAddCookbookTagFilter} className="btn-add-filter">+</button>
|
||||
</div>
|
||||
<datalist id="available-cookbook-filter-tags">
|
||||
{availableTags.map(tag => (
|
||||
<option key={tag.id} value={tag.name} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" onClick={() => setShowCreateModal(false)} className="btn-secondary">
|
||||
Cancel
|
||||
|
||||
@@ -20,9 +20,13 @@ function EditCookbook() {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [autoFilterCategories, setAutoFilterCategories] = useState<string[]>([]);
|
||||
const [autoFilterTags, setAutoFilterTags] = useState<string[]>([]);
|
||||
const [autoFilterCookbookTags, setAutoFilterCookbookTags] = useState<string[]>([]);
|
||||
const [cookbookTags, setCookbookTags] = useState<string[]>([]);
|
||||
|
||||
const [categoryInput, setCategoryInput] = useState('');
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [cookbookTagInput, setCookbookTagInput] = useState('');
|
||||
const [cookbookTagFilterInput, setCookbookTagFilterInput] = useState('');
|
||||
const [availableTags, setAvailableTags] = useState<Tag[]>([]);
|
||||
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
|
||||
|
||||
@@ -47,6 +51,8 @@ function EditCookbook() {
|
||||
setCoverImageUrl(cookbook.coverImageUrl || '');
|
||||
setAutoFilterCategories(cookbook.autoFilterCategories || []);
|
||||
setAutoFilterTags(cookbook.autoFilterTags || []);
|
||||
setAutoFilterCookbookTags(cookbook.autoFilterCookbookTags || []);
|
||||
setCookbookTags(cookbook.tags || []);
|
||||
}
|
||||
|
||||
setAvailableTags(tagsResponse.data || []);
|
||||
@@ -86,7 +92,9 @@ function EditCookbook() {
|
||||
description: description || undefined,
|
||||
coverImageUrl: coverImageUrl === '' ? '' : (coverImageUrl || undefined),
|
||||
autoFilterCategories,
|
||||
autoFilterTags
|
||||
autoFilterTags,
|
||||
autoFilterCookbookTags,
|
||||
tags: cookbookTags
|
||||
});
|
||||
|
||||
navigate(`/cookbooks/${id}`);
|
||||
@@ -122,6 +130,30 @@ function EditCookbook() {
|
||||
setAutoFilterTags(autoFilterTags.filter(t => t !== tag));
|
||||
};
|
||||
|
||||
const handleAddCookbookTag = () => {
|
||||
const trimmed = cookbookTagInput.trim();
|
||||
if (trimmed && !cookbookTags.includes(trimmed)) {
|
||||
setCookbookTags([...cookbookTags, trimmed]);
|
||||
setCookbookTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCookbookTag = (tag: string) => {
|
||||
setCookbookTags(cookbookTags.filter(t => t !== tag));
|
||||
};
|
||||
|
||||
const handleAddCookbookTagFilter = () => {
|
||||
const trimmed = cookbookTagFilterInput.trim();
|
||||
if (trimmed && !autoFilterCookbookTags.includes(trimmed)) {
|
||||
setAutoFilterCookbookTags([...autoFilterCookbookTags, trimmed]);
|
||||
setCookbookTagFilterInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCookbookTagFilter = (tag: string) => {
|
||||
setAutoFilterCookbookTags(autoFilterCookbookTags.filter(t => t !== tag));
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setSelectedFile(e.target.files[0]);
|
||||
@@ -373,6 +405,72 @@ function EditCookbook() {
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Cookbook Tags</label>
|
||||
<p className="help-text">
|
||||
Tags to categorize this cookbook (e.g., "holiday", "quick-meals", "vegetarian")
|
||||
</p>
|
||||
<div className="filter-chips">
|
||||
{cookbookTags.map(tag => (
|
||||
<span key={tag} className="filter-chip">
|
||||
{tag}
|
||||
<button type="button" onClick={() => handleRemoveCookbookTag(tag)}>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="input-with-button">
|
||||
<input
|
||||
type="text"
|
||||
value={cookbookTagInput}
|
||||
onChange={(e) => setCookbookTagInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddCookbookTag())}
|
||||
placeholder="Add tag"
|
||||
list="available-cookbook-tags-edit"
|
||||
/>
|
||||
<button type="button" onClick={handleAddCookbookTag} className="btn-add-filter">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<datalist id="available-cookbook-tags-edit">
|
||||
{availableTags.map(tag => (
|
||||
<option key={tag.id} value={tag.name} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Auto-Include Cookbooks by Tags</label>
|
||||
<p className="help-text">
|
||||
Other cookbooks with these tags will be automatically included in this cookbook
|
||||
</p>
|
||||
<div className="filter-chips">
|
||||
{autoFilterCookbookTags.map(tag => (
|
||||
<span key={tag} className="filter-chip">
|
||||
{tag}
|
||||
<button type="button" onClick={() => handleRemoveCookbookTagFilter(tag)}>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="input-with-button">
|
||||
<input
|
||||
type="text"
|
||||
value={cookbookTagFilterInput}
|
||||
onChange={(e) => setCookbookTagFilterInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddCookbookTagFilter())}
|
||||
placeholder="Add tag to filter by"
|
||||
list="available-cookbook-filter-tags-edit"
|
||||
/>
|
||||
<button type="button" onClick={handleAddCookbookTagFilter} className="btn-add-filter">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<datalist id="available-cookbook-filter-tags-edit">
|
||||
{availableTags.map(tag => (
|
||||
<option key={tag.id} value={tag.name} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" onClick={() => navigate(`/cookbooks/${id}`)} className="btn-secondary">
|
||||
Cancel
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import axios from 'axios';
|
||||
import { Recipe, RecipeImportRequest, RecipeImportResponse, ApiResponse, PaginatedResponse, Cookbook, CookbookWithRecipes, Tag } from '@basil/shared';
|
||||
import {
|
||||
Recipe,
|
||||
RecipeImportRequest,
|
||||
RecipeImportResponse,
|
||||
ApiResponse,
|
||||
PaginatedResponse,
|
||||
Cookbook,
|
||||
CookbookWithRecipes,
|
||||
Tag,
|
||||
MealPlan,
|
||||
MealPlanQueryParams,
|
||||
CreateMealPlanRequest,
|
||||
UpdateMealPlanRequest,
|
||||
CreateMealRequest,
|
||||
UpdateMealRequest,
|
||||
ShoppingListRequest,
|
||||
ShoppingListResponse
|
||||
} from '@basil/shared';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
@@ -8,6 +25,20 @@ const api = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// Add request interceptor to inject auth token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('basil_access_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export const recipesApi = {
|
||||
getAll: async (params?: {
|
||||
page?: number;
|
||||
@@ -74,8 +105,10 @@ export const recipesApi = {
|
||||
};
|
||||
|
||||
export const cookbooksApi = {
|
||||
getAll: async (): Promise<ApiResponse<Cookbook[]>> => {
|
||||
const response = await api.get('/cookbooks');
|
||||
getAll: async (includeChildren: boolean = false): Promise<ApiResponse<Cookbook[]>> => {
|
||||
const response = await api.get('/cookbooks', {
|
||||
params: { includeChildren: includeChildren.toString() }
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -84,12 +117,12 @@ export const cookbooksApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (cookbook: { name: string; description?: string; coverImageUrl?: string; autoFilterCategories?: string[]; autoFilterTags?: string[] }): Promise<ApiResponse<Cookbook>> => {
|
||||
create: async (cookbook: { name: string; description?: string; coverImageUrl?: string; autoFilterCategories?: string[]; autoFilterTags?: string[]; autoFilterCookbookTags?: string[]; tags?: string[] }): Promise<ApiResponse<Cookbook>> => {
|
||||
const response = await api.post('/cookbooks', cookbook);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, cookbook: { name?: string; description?: string; coverImageUrl?: string; autoFilterCategories?: string[]; autoFilterTags?: string[] }): Promise<ApiResponse<Cookbook>> => {
|
||||
update: async (id: string, cookbook: { name?: string; description?: string; coverImageUrl?: string; autoFilterCategories?: string[]; autoFilterTags?: string[]; autoFilterCookbookTags?: string[]; tags?: string[] }): Promise<ApiResponse<Cookbook>> => {
|
||||
const response = await api.put(`/cookbooks/${id}`, cookbook);
|
||||
return response.data;
|
||||
},
|
||||
@@ -109,6 +142,16 @@ export const cookbooksApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
addCookbook: async (cookbookId: string, childCookbookId: string): Promise<ApiResponse<void>> => {
|
||||
const response = await api.post(`/cookbooks/${cookbookId}/cookbooks/${childCookbookId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
removeCookbook: async (cookbookId: string, childCookbookId: string): Promise<ApiResponse<void>> => {
|
||||
const response = await api.delete(`/cookbooks/${cookbookId}/cookbooks/${childCookbookId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
uploadImage: async (id: string, file: File): Promise<ApiResponse<{ url: string }>> => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
@@ -141,4 +184,56 @@ export const tagsApi = {
|
||||
},
|
||||
};
|
||||
|
||||
export const mealPlansApi = {
|
||||
getAll: async (params: MealPlanQueryParams): Promise<ApiResponse<MealPlan[]>> => {
|
||||
const response = await api.get('/meal-plans', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getByDate: async (date: string): Promise<ApiResponse<MealPlan | null>> => {
|
||||
const response = await api.get(`/meal-plans/date/${date}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<ApiResponse<MealPlan>> => {
|
||||
const response = await api.get(`/meal-plans/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateMealPlanRequest): Promise<ApiResponse<MealPlan>> => {
|
||||
const response = await api.post('/meal-plans', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateMealPlanRequest): Promise<ApiResponse<MealPlan>> => {
|
||||
const response = await api.put(`/meal-plans/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<ApiResponse<void>> => {
|
||||
const response = await api.delete(`/meal-plans/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
addMeal: async (mealPlanId: string, meal: CreateMealRequest): Promise<ApiResponse<any>> => {
|
||||
const response = await api.post(`/meal-plans/${mealPlanId}/meals`, meal);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateMeal: async (mealId: string, meal: UpdateMealRequest): Promise<ApiResponse<any>> => {
|
||||
const response = await api.put(`/meal-plans/meals/${mealId}`, meal);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
removeMeal: async (mealId: string): Promise<ApiResponse<void>> => {
|
||||
const response = await api.delete(`/meal-plans/meals/${mealId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
generateShoppingList: async (params: ShoppingListRequest): Promise<ApiResponse<ShoppingListResponse>> => {
|
||||
const response = await api.post('/meal-plans/shopping-list', params);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -431,3 +431,30 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Included Cookbooks Section */
|
||||
.included-cookbooks-section {
|
||||
margin: 2rem 0;
|
||||
padding: 1.5rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.included-cookbooks-section h2 {
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.cookbook-card.nested {
|
||||
border: 2px solid #e0e0e0;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.cookbook-card.nested:hover {
|
||||
border-color: #2e7d32;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@@ -416,3 +416,31 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Cookbook stats (recipe count and cookbook count) */
|
||||
.cookbook-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.cookbook-count {
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Cookbook tags */
|
||||
.cookbook-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.cookbook-tags .tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user