import { z } from "zod"; import { BRAND } from "@app/shared-types"; import type { Connector, ConnectorContext, ConnectorHealth, ImportResult } from "./types.js"; /** * Open Food Facts – öppen produktdatabas (ODbL-licens) för streckkodsuppslag * (spec §11 steg 3: "kontrollera tillåten extern datakälla"). * Attribution krävs enligt licensen och visas i appen. */ const offProductSchema = z.object({ code: z.string(), product: z .object({ product_name: z.string().optional(), brands: z.string().optional(), quantity: z.string().optional(), ingredients_text: z.string().optional(), allergens_tags: z.array(z.string()).optional(), nutriments: z.record(z.string(), z.unknown()).optional(), image_url: z.string().optional(), lang: z.string().optional(), }) .optional(), status: z.number().optional(), }); export interface OffProduct { gtin: string; name?: string | undefined; brand?: string | undefined; quantityText?: string | undefined; ingredientsText?: string | undefined; allergenTags: string[]; nutrimentsPer100g: Partial<{ kcal: number; proteinG: number; carbsG: number; fatG: number; saturatedFatG: number; fiberG: number; sugarG: number; saltG: number; }>; imageUrl?: string | undefined; } export class OpenFoodFactsConnector implements Connector { readonly id = "open-food-facts" as const; readonly nameSv = "Open Food Facts"; readonly legalBasis = "open_data" as const; readonly attributionSv = "Produktdata: Open Food Facts (ODbL)"; constructor( private readonly baseUrl: string = process.env.OFF_API_URL ?? "https://world.openfoodfacts.org", private readonly fetchImpl: typeof fetch = fetch, ) {} async connect(): Promise {} async authenticate(): Promise { return true; } /** Slå upp en produkt via GTIN/EAN. */ async lookupBarcode(gtin: string): Promise { const res = await this.fetchImpl(`${this.baseUrl}/api/v2/product/${gtin}.json`, { headers: { "user-agent": `${BRAND.name}/0.1 (kontakt: ${BRAND.supportEmail})` }, signal: AbortSignal.timeout(10_000), }); if (res.status === 404) return null; if (!res.ok) throw new Error(`Open Food Facts svarade ${res.status}`); const parsed = offProductSchema.safeParse(await res.json()); if (!parsed.success || !parsed.data.product) return null; const p = parsed.data.product; const n = (p.nutriments ?? {}) as Record; const num = (key: string): number | undefined => { const v = n[key]; return typeof v === "number" && Number.isFinite(v) ? v : undefined; }; const per100: OffProduct["nutrimentsPer100g"] = {}; const kcal = num("energy-kcal_100g"); if (kcal !== undefined) per100.kcal = kcal; const protein = num("proteins_100g"); if (protein !== undefined) per100.proteinG = protein; const carbs = num("carbohydrates_100g"); if (carbs !== undefined) per100.carbsG = carbs; const fat = num("fat_100g"); if (fat !== undefined) per100.fatG = fat; const satFat = num("saturated-fat_100g"); if (satFat !== undefined) per100.saturatedFatG = satFat; const fiber = num("fiber_100g"); if (fiber !== undefined) per100.fiberG = fiber; const sugar = num("sugars_100g"); if (sugar !== undefined) per100.sugarG = sugar; const salt = num("salt_100g"); if (salt !== undefined) per100.saltG = salt; return { gtin: parsed.data.code, name: p.product_name, brand: p.brands, quantityText: p.quantity, ingredientsText: p.ingredients_text, allergenTags: p.allergens_tags ?? [], nutrimentsPer100g: per100, imageUrl: p.image_url, }; } async importData( _ctx: ConnectorContext, params?: Record, ): Promise> { const gtin = typeof params?.gtin === "string" ? params.gtin : null; if (!gtin) return { items: [], source: this.id, importedAt: new Date().toISOString(), warnings: ["gtin saknas"], }; const product = await this.lookupBarcode(gtin); return { items: product ? [product] : [], source: this.id, importedAt: new Date().toISOString(), warnings: [], }; } async sync(ctx: ConnectorContext): Promise> { return this.importData(ctx); } async disconnect(): Promise {} async healthCheck(): Promise { try { const res = await this.fetchImpl(`${this.baseUrl}/api/v2/product/7310865004703.json`, { headers: { "user-agent": `${BRAND.name}/0.1` }, signal: AbortSignal.timeout(8_000), }); return { ok: res.ok || res.status === 404, detail: `status ${res.status}`, checkedAt: new Date().toISOString(), }; } catch (err) { return { ok: false, detail: err instanceof Error ? err.message : String(err), checkedAt: new Date().toISOString(), }; } } }