55c6fc5ab7
Extends the existing feature-flags system (previously 3 keys, all default-enabled) with 6 more: recipe_import_url, recipe_import_photo, nutrition_estimation, markdown_export, weekly_nutrition, grocery_delivery. Each FEATURE_DEFINITIONS entry now carries its own defaultEnabled -- the new 6 default to false, the original 3 stay true -- so no migration/seed was needed for the "off by default" requirement, just a per-key fallback instead of a blanket true. Gated server-side (requireFeatureEnabledResponse, a new shared helper avoiding six copies of the same try/catch) on: import-url, import-photo, nutrition POST estimate, bulk markdown export, weekly meal-plan nutrition GET, Instacart export. Gated client-side by hiding the trigger entirely (not just disabling) on every page that renders one: recipe detail (meal/drink pairing buttons, nutrition panel's estimate button, markdown export), recipes list (import-URL button, including the OS Share Target auto-import path), new-recipe page (photo import), meal-plan page (markdown export, weekly nutrition bar), shopping-list/collection/pantry pages (markdown export), shopping-list page (Instacart button, now gated by both the existing env-var check AND the tier flag). Also: BYOK section on Settings -> AI now hidden entirely for non-BYOK users (previously showed a locked-and-teased notice, same inconsistency the Model Prefs fix closed yesterday). Language switcher shows a flag icon (FlagGB/FlagFR, moved from components/marketing to components/shared so both the logged-in settings switcher and the logged-out marketing one can use it) instead of plain "English"/"Français" text-only options. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
118 lines
4.2 KiB
TypeScript
118 lines
4.2 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { db, mealPlans, mealPlanEntries, userNutritionGoals, eq, and } from "@epicure/db";
|
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
|
import { requireFeatureEnabledResponse } from "@/lib/feature-flags";
|
|
|
|
type Params = { params: Promise<{ weekStart: string }> };
|
|
|
|
type Weekday = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
|
|
|
|
type Totals = { calories: number; protein: number; carbs: number; fat: number };
|
|
|
|
function emptyTotals(): Totals {
|
|
return { calories: 0, protein: 0, carbs: 0, fat: 0 };
|
|
}
|
|
|
|
export async function GET(req: NextRequest, { params }: Params) {
|
|
const { session, response } = await requireSessionOrApiKey(req);
|
|
if (response) return response;
|
|
|
|
const featureDenied = await requireFeatureEnabledResponse(session!.user.id, "weekly_nutrition");
|
|
if (featureDenied) return featureDenied;
|
|
|
|
const { weekStart } = await params;
|
|
const userId = session!.user.id;
|
|
|
|
const plan = await db.query.mealPlans.findFirst({
|
|
where: and(eq(mealPlans.userId, userId), eq(mealPlans.weekStart, weekStart)),
|
|
});
|
|
|
|
const weekTotals = emptyTotals();
|
|
const byDay: Record<Weekday, Totals> = {
|
|
mon: emptyTotals(), tue: emptyTotals(), wed: emptyTotals(), thu: emptyTotals(),
|
|
fri: emptyTotals(), sat: emptyTotals(), sun: emptyTotals(),
|
|
};
|
|
let unknownCount = 0;
|
|
let plannedDays = 0;
|
|
|
|
if (plan) {
|
|
// batchDishId entries always carry their parent recipeId too (enforced at
|
|
// entry-creation time), so joining on `recipe` already covers batch
|
|
// dishes — there's no separate per-dish nutrition data to look up.
|
|
const entries = await db.query.mealPlanEntries.findMany({
|
|
where: eq(mealPlanEntries.mealPlanId, plan.id),
|
|
with: {
|
|
recipe: {
|
|
columns: { id: true, baseServings: true, nutritionData: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
const daysWithEntries = new Set<string>();
|
|
for (const entry of entries) {
|
|
daysWithEntries.add(entry.day);
|
|
const recipe = entry.recipe;
|
|
if (!recipe || !recipe.nutritionData?.perServing) {
|
|
unknownCount++;
|
|
continue;
|
|
}
|
|
|
|
const { calories, proteinG, carbsG, fatG } = recipe.nutritionData.perServing;
|
|
const servings = entry.servings ?? recipe.baseServings;
|
|
const day = byDay[entry.day as Weekday];
|
|
|
|
const cals = Math.round(calories * servings);
|
|
const protein = Math.round(proteinG * servings);
|
|
const carbs = Math.round(carbsG * servings);
|
|
const fat = Math.round(fatG * servings);
|
|
|
|
weekTotals.calories += cals;
|
|
weekTotals.protein += protein;
|
|
weekTotals.carbs += carbs;
|
|
weekTotals.fat += fat;
|
|
day.calories += cals;
|
|
day.protein += protein;
|
|
day.carbs += carbs;
|
|
day.fat += fat;
|
|
}
|
|
plannedDays = daysWithEntries.size;
|
|
}
|
|
|
|
const goals = await db.query.userNutritionGoals.findFirst({
|
|
where: eq(userNutritionGoals.userId, userId),
|
|
});
|
|
|
|
const goalsData = goals
|
|
? { caloriesKcal: goals.caloriesKcal, proteinG: goals.proteinG, carbsG: goals.carbsG, fatG: goals.fatG }
|
|
: null;
|
|
|
|
// Goals are daily targets (see the nutrition diary, which compares a single
|
|
// day's totals against them directly) — a week's raw total is ~7x a daily
|
|
// goal, so coverage must compare against the daily AVERAGE across days that
|
|
// actually have planned meals, not the week's total.
|
|
const divisor = plannedDays || 1;
|
|
const dailyAverage: Totals = {
|
|
calories: Math.round(weekTotals.calories / divisor),
|
|
protein: Math.round(weekTotals.protein / divisor),
|
|
carbs: Math.round(weekTotals.carbs / divisor),
|
|
fat: Math.round(weekTotals.fat / divisor),
|
|
};
|
|
|
|
const coverage = {
|
|
calories: goalsData?.caloriesKcal ? Math.round((dailyAverage.calories / goalsData.caloriesKcal) * 100) : 0,
|
|
protein: goalsData?.proteinG ? Math.round((dailyAverage.protein / goalsData.proteinG) * 100) : 0,
|
|
carbs: goalsData?.carbsG ? Math.round((dailyAverage.carbs / goalsData.carbsG) * 100) : 0,
|
|
fat: goalsData?.fatG ? Math.round((dailyAverage.fat / goalsData.fatG) * 100) : 0,
|
|
};
|
|
|
|
return NextResponse.json({
|
|
totals: weekTotals,
|
|
dailyAverage,
|
|
byDay,
|
|
plannedDays,
|
|
unknownCount,
|
|
goals: goalsData,
|
|
coverage,
|
|
});
|
|
}
|