Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+18
View File
@@ -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"
}
}
+60
View File
@@ -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";
}
+23
View File
@@ -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;
}
+115
View File
@@ -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(),
};
}
}
}
+155
View File
@@ -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(),
};
}
}
}
+84
View File
@@ -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;
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"],
"compilerOptions": {
"types": ["node"]
}
}