integrate barcode scan with openfoodfacts and meal type selection

This commit is contained in:
echo 2026-03-31 16:36:18 +02:00
parent 3c3dfb6cd6
commit ca64a100b6
5 changed files with 890 additions and 428 deletions

View File

@ -0,0 +1,156 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getUserMembershipContext } from "@/lib/membership/access";
import log from "@/lib/logger";
interface OpenFoodFactsProduct {
product_name?: string;
product_name_en?: string;
brands?: string;
image_url?: string;
image_front_url?: string;
serving_size?: string;
nutriments?: {
[key: string]: number | string | undefined;
"energy-kcal_serving"?: number;
"energy-kcal_100g"?: number;
proteins_serving?: number;
proteins_100g?: number;
carbohydrates_serving?: number;
carbohydrates_100g?: number;
fat_serving?: number;
fat_100g?: number;
};
}
interface OpenFoodFactsResponse {
status: number;
code: string;
product?: OpenFoodFactsProduct;
}
function normalizeBarcode(rawCode: string): string {
return rawCode.replace(/\D/g, "").trim();
}
function isSupportedBarcode(code: string): boolean {
return [8, 12, 13].includes(code.length);
}
function getNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
function buildProductPayload(code: string, product: OpenFoodFactsProduct) {
const caloriesPerServing =
getNumber(product.nutriments?.["energy-kcal_serving"]) ??
getNumber(product.nutriments?.["energy-kcal_100g"]) ??
0;
const protein =
getNumber(product.nutriments?.proteins_serving) ??
getNumber(product.nutriments?.proteins_100g);
const carbs =
getNumber(product.nutriments?.carbohydrates_serving) ??
getNumber(product.nutriments?.carbohydrates_100g);
const fat =
getNumber(product.nutriments?.fat_serving) ??
getNumber(product.nutriments?.fat_100g);
return {
barcode: code,
name: product.product_name || product.product_name_en || "Unknown Product",
brand: product.brands || null,
imageUrl: product.image_url || product.image_front_url || null,
servingSize: product.serving_size || "1 serving",
caloriesPerServing: Math.max(0, Math.round(caloriesPerServing)),
macros: {
protein: protein ?? null,
carbs: carbs ?? null,
fat: fat ?? null,
},
source: "openfoodfacts" as const,
};
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ code: string }> },
) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { features, membershipType } = await getUserMembershipContext(userId);
if (!features.nutritionTracking) {
return NextResponse.json(
{
error:
"Barcode food scan is available on Premium and VIP memberships",
membershipType,
},
{ status: 403 },
);
}
const { code: rawCode } = await params;
const code = normalizeBarcode(rawCode);
if (!isSupportedBarcode(code)) {
return NextResponse.json(
{ error: "Invalid barcode. Use EAN-8, UPC-A, or EAN-13 formats." },
{ status: 400 },
);
}
const response = await fetch(
`https://world.openfoodfacts.org/api/v2/product/${code}.json`,
{
headers: {
"User-Agent": "FitAI/1.0 (fitai.app)",
},
cache: "no-store",
},
);
if (!response.ok) {
log.warn("OpenFoodFacts lookup failed", {
status: response.status,
barcode: code,
});
return NextResponse.json(
{ error: "Food lookup service unavailable. Please try again." },
{ status: 503 },
);
}
const payload = (await response.json()) as OpenFoodFactsResponse;
if (payload.status !== 1 || !payload.product) {
return NextResponse.json(
{ error: "Product not found in OpenFoodFacts" },
{ status: 404 },
);
}
return NextResponse.json({
success: true,
data: buildProductPayload(code, payload.product),
meta: {
timestamp: new Date().toISOString(),
},
});
} catch (error) {
log.error("Failed barcode food lookup", error);
return NextResponse.json(
{ error: "Failed to lookup food barcode" },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,35 @@
import { apiClient, withAuth } from "./client";
import { API_ENDPOINTS } from "../config/api";
export interface ScannedFoodProduct {
barcode: string;
name: string;
brand: string | null;
imageUrl: string | null;
servingSize: string;
caloriesPerServing: number;
macros: {
protein: number | null;
carbs: number | null;
fat: number | null;
};
source: "openfoodfacts";
}
interface FoodLookupResponse {
success: boolean;
data: ScannedFoodProduct;
}
export async function lookupFoodByBarcode(
barcode: string,
token: string | null,
): Promise<ScannedFoodProduct> {
const normalized = barcode.replace(/\D/g, "");
const response = await apiClient.get<FoodLookupResponse>(
API_ENDPOINTS.FOOD.LOOKUP_BARCODE(normalized),
withAuth(token),
);
return response.data.data;
}

View File

@ -15,4 +15,5 @@ export * from "./hydration";
export * from "./client"; export * from "./client";
export * from "./helpers"; export * from "./helpers";
export * from "./membership"; export * from "./membership";
export * from "./food";
export * from "./gyms"; export * from "./gyms";

File diff suppressed because it is too large Load Diff

View File

@ -47,6 +47,9 @@ export const API_ENDPOINTS = {
MEMBERSHIP: { MEMBERSHIP: {
FEATURES: "/api/membership/features", FEATURES: "/api/membership/features",
}, },
FOOD: {
LOOKUP_BARCODE: (code: string) => `/api/food/barcode/${code}`,
},
NUTRITION: { NUTRITION: {
BASE: "/api/nutrition", BASE: "/api/nutrition",
MEALS: "/api/nutrition/meals", MEALS: "/api/nutrition/meals",