Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@app/connectors",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Connector-interface (spec §43): Livsmedelsverket, Open Food Facts, hälsa, framtida butiker/vitvaror",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@app/shared-types": "workspace:*",
|
||||
"zod": "^4.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Connector, ConnectorContext, ConnectorHealth, ImportResult } from "./types.js";
|
||||
|
||||
/**
|
||||
* Hälsointegrationer (spec §42). Själva datainsamlingen sker i mobilappen
|
||||
* (HealthKit/Health Connect körs på enheten); backend-connectorn tar emot
|
||||
* det appen skickar och normaliserar. Kräver separat samtycke
|
||||
* (consent kind: health_integration) och datan hålls i hälsodomänen (spec §56).
|
||||
*/
|
||||
|
||||
export interface HealthSample {
|
||||
type: "weight_kg" | "steps" | "active_kcal" | "workout" | "sleep_minutes" | "water_ml";
|
||||
value: number;
|
||||
unit: string;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
sourceDevice?: string | undefined;
|
||||
}
|
||||
|
||||
abstract class DeviceHealthConnector implements Connector<HealthSample> {
|
||||
abstract readonly id: "apple-health" | "health-connect";
|
||||
abstract readonly nameSv: string;
|
||||
readonly legalBasis = "user_consent_platform_api" as const;
|
||||
|
||||
async connect(): Promise<void> {
|
||||
/* Godkännande sker på enheten. */
|
||||
}
|
||||
async authenticate(ctx: ConnectorContext): Promise<boolean> {
|
||||
return Boolean(ctx.userId);
|
||||
}
|
||||
async importData(
|
||||
_ctx: ConnectorContext,
|
||||
params?: Record<string, unknown>,
|
||||
): Promise<ImportResult<HealthSample>> {
|
||||
// Appen POST:ar samples till /v1/health-data; denna metod normaliserar dem.
|
||||
const samples = Array.isArray(params?.samples) ? (params.samples as HealthSample[]) : [];
|
||||
return {
|
||||
items: samples,
|
||||
source: this.id,
|
||||
importedAt: new Date().toISOString(),
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
async sync(ctx: ConnectorContext): Promise<ImportResult<HealthSample>> {
|
||||
return this.importData(ctx);
|
||||
}
|
||||
async disconnect(): Promise<void> {}
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
return { ok: true, detail: "device-side connector", checkedAt: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
|
||||
export class AppleHealthConnector extends DeviceHealthConnector {
|
||||
override readonly id = "apple-health" as const;
|
||||
override readonly nameSv = "Apple Health";
|
||||
}
|
||||
|
||||
export class HealthConnectConnector extends DeviceHealthConnector {
|
||||
override readonly id = "health-connect" as const;
|
||||
override readonly nameSv = "Android Health Connect";
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export * from "./types.js";
|
||||
export * from "./livsmedelsverket.js";
|
||||
export * from "./openFoodFacts.js";
|
||||
export * from "./health.js";
|
||||
|
||||
import { ConnectorRegistry } from "./types.js";
|
||||
import { LivsmedelsverketConnector } from "./livsmedelsverket.js";
|
||||
import { OpenFoodFactsConnector } from "./openFoodFacts.js";
|
||||
import { AppleHealthConnector, HealthConnectConnector } from "./health.js";
|
||||
|
||||
/**
|
||||
* Standardregistret. Framtida connectors (butiker, smarta kylskåp, ugnar,
|
||||
* vågar – spec §60) registreras här NÄR laglig åtkomst och avtal finns,
|
||||
* aldrig före (spec §43: ingen reverse engineering som kärnfunktion).
|
||||
*/
|
||||
export function createDefaultRegistry(): ConnectorRegistry {
|
||||
const registry = new ConnectorRegistry();
|
||||
registry.register(new LivsmedelsverketConnector());
|
||||
registry.register(new OpenFoodFactsConnector());
|
||||
registry.register(new AppleHealthConnector());
|
||||
registry.register(new HealthConnectConnector());
|
||||
return registry;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { z } from "zod";
|
||||
import type { Connector, ConnectorContext, ConnectorHealth, ImportResult } from "./types.js";
|
||||
|
||||
/**
|
||||
* Livsmedelsverkets livsmedelsdatabas – öppna data (laglig kärnkälla för
|
||||
* näringsvärden, spec §15/§26/§61.11). Detta är den avsedda produktionskällan
|
||||
* som ersätter seed-estimaten i canonical_ingredients.
|
||||
*
|
||||
* API-dokumentation: https://dataportal.livsmedelsverket.se
|
||||
* (endpoint konfigureras via LIVSMEDELSVERKET_API_URL – verifiera aktuell
|
||||
* version innan produktionsimport; formatet nedan valideras med Zod).
|
||||
*/
|
||||
|
||||
const livsmedelSchema = z.object({
|
||||
nummer: z.number().or(z.string()),
|
||||
namn: z.string(),
|
||||
naringsvarden: z
|
||||
.array(
|
||||
z.object({
|
||||
namn: z.string(),
|
||||
varde: z.number().nullable(),
|
||||
enhet: z.string().nullable(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export interface LivsmedelsverketItem {
|
||||
externalId: string;
|
||||
nameSv: string;
|
||||
nutrients: Array<{ name: string; value: number | null; unit: string | null }>;
|
||||
}
|
||||
|
||||
export class LivsmedelsverketConnector implements Connector<LivsmedelsverketItem> {
|
||||
readonly id = "livsmedelsverket" as const;
|
||||
readonly nameSv = "Livsmedelsverkets livsmedelsdatabas";
|
||||
readonly legalBasis = "open_data" as const;
|
||||
readonly attributionSv = "Näringsdata: Livsmedelsverkets livsmedelsdatabas";
|
||||
|
||||
constructor(
|
||||
private readonly baseUrl: string = process.env.LIVSMEDELSVERKET_API_URL ??
|
||||
"https://dataportal.livsmedelsverket.se/livsmedel/api/v1",
|
||||
private readonly fetchImpl: typeof fetch = fetch,
|
||||
) {}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
/* Öppna data – ingen anslutning krävs. */
|
||||
}
|
||||
async authenticate(): Promise<boolean> {
|
||||
return true; // Öppet API utan nyckel.
|
||||
}
|
||||
|
||||
async importData(
|
||||
_ctx: ConnectorContext,
|
||||
params: { offset?: number; limit?: number } = {},
|
||||
): Promise<ImportResult<LivsmedelsverketItem>> {
|
||||
const limit = params.limit ?? 100;
|
||||
const offset = params.offset ?? 0;
|
||||
const warnings: string[] = [];
|
||||
const url = `${this.baseUrl}/livsmedel?offset=${offset}&limit=${limit}`;
|
||||
const res = await this.fetchImpl(url, { signal: AbortSignal.timeout(20_000) });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Livsmedelsverket svarade ${res.status} – kontrollera API-version/URL.`);
|
||||
}
|
||||
const json: unknown = await res.json();
|
||||
const rawItems: unknown[] = Array.isArray(json)
|
||||
? json
|
||||
: typeof json === "object" &&
|
||||
json !== null &&
|
||||
Array.isArray((json as { livsmedel?: unknown[] }).livsmedel)
|
||||
? (json as { livsmedel: unknown[] }).livsmedel
|
||||
: [];
|
||||
|
||||
const items: LivsmedelsverketItem[] = [];
|
||||
for (const raw of rawItems) {
|
||||
const parsed = livsmedelSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
warnings.push("Rad kunde inte tolkas och hoppades över.");
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
externalId: String(parsed.data.nummer),
|
||||
nameSv: parsed.data.namn,
|
||||
nutrients: (parsed.data.naringsvarden ?? []).map((n) => ({
|
||||
name: n.namn,
|
||||
value: n.varde,
|
||||
unit: n.enhet,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return { items, source: this.id, importedAt: new Date().toISOString(), warnings };
|
||||
}
|
||||
|
||||
async sync(ctx: ConnectorContext): Promise<ImportResult<LivsmedelsverketItem>> {
|
||||
return this.importData(ctx);
|
||||
}
|
||||
async disconnect(): Promise<void> {
|
||||
/* noop */
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
try {
|
||||
const res = await this.fetchImpl(`${this.baseUrl}/livsmedel?offset=0&limit=1`, {
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
return { ok: res.ok, 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
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<OffProduct> {
|
||||
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<void> {}
|
||||
async authenticate(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Slå upp en produkt via GTIN/EAN. */
|
||||
async lookupBarcode(gtin: string): Promise<OffProduct | null> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>,
|
||||
): Promise<ImportResult<OffProduct>> {
|
||||
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<ImportResult<OffProduct>> {
|
||||
return this.importData(ctx);
|
||||
}
|
||||
async disconnect(): Promise<void> {}
|
||||
|
||||
async healthCheck(): Promise<ConnectorHealth> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Connector-ramverk (spec §43). Gemensamt interface:
|
||||
* connect, authenticate, import, sync, disconnect, healthCheck.
|
||||
*
|
||||
* Juridisk grundregel (spec §26, §43, §61.11): endast lagliga API:er,
|
||||
* partnerflöden eller licensierade data. Ingen reverse engineering,
|
||||
* ingen scraping som kärnfunktion.
|
||||
*/
|
||||
|
||||
export type ConnectorId =
|
||||
| "livsmedelsverket"
|
||||
| "open-food-facts"
|
||||
| "apple-health"
|
||||
| "health-connect"
|
||||
| "digital-receipts"
|
||||
| "future-retailers"
|
||||
| "future-smart-fridges"
|
||||
| "future-smart-ovens"
|
||||
| "future-scales";
|
||||
|
||||
export interface ConnectorHealth {
|
||||
ok: boolean;
|
||||
detail?: string;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface ConnectorContext {
|
||||
userId?: string;
|
||||
householdId?: string;
|
||||
credentials?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ImportResult<T = unknown> {
|
||||
items: T[];
|
||||
source: ConnectorId;
|
||||
importedAt: string;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface Connector<TImport = unknown> {
|
||||
id: ConnectorId;
|
||||
nameSv: string;
|
||||
/** Juridisk status – en connector utan klar legal basis får inte aktiveras. */
|
||||
legalBasis: "open_data" | "licensed" | "user_consent_platform_api" | "agreement_pending";
|
||||
attributionSv?: string;
|
||||
|
||||
connect(ctx: ConnectorContext): Promise<void>;
|
||||
authenticate(ctx: ConnectorContext): Promise<boolean>;
|
||||
importData(
|
||||
ctx: ConnectorContext,
|
||||
params?: Record<string, unknown>,
|
||||
): Promise<ImportResult<TImport>>;
|
||||
sync(ctx: ConnectorContext): Promise<ImportResult<TImport>>;
|
||||
disconnect(ctx: ConnectorContext): Promise<void>;
|
||||
healthCheck(): Promise<ConnectorHealth>;
|
||||
}
|
||||
|
||||
export class ConnectorRegistry {
|
||||
private connectors = new Map<ConnectorId, Connector>();
|
||||
|
||||
register(connector: Connector): void {
|
||||
this.connectors.set(connector.id, connector);
|
||||
}
|
||||
|
||||
get(id: ConnectorId): Connector | undefined {
|
||||
return this.connectors.get(id);
|
||||
}
|
||||
|
||||
list(): Connector[] {
|
||||
return [...this.connectors.values()];
|
||||
}
|
||||
|
||||
async healthCheckAll(): Promise<Record<string, ConnectorHealth>> {
|
||||
const result: Record<string, ConnectorHealth> = {};
|
||||
for (const c of this.connectors.values()) {
|
||||
result[c.id] = await c.healthCheck().catch((err) => ({
|
||||
ok: false,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
checkedAt: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user