Bli med i Kassalapp-fellesskapet på Discord

Kassalapp API med Node.js - Komplett Guide

Innledning

Kassalapp API gir deg tilgang til prisinformasjon fra norske dagligvarebutikker. Denne guiden viser deg hvordan du integrerer API-et i dine Node.js-prosjekter med moderne JavaScript og TypeScript.

Innhold

  1. Kom i gang
  2. Autentisering
  3. Oppsett med Axios
  4. TypeScript-grensesnitt
  5. Produktsøk
  6. Filtrering og sortering
  7. Strekkodeskanning
  8. Butikklokasjoner
  9. Feilhåndtering
  10. Rate limiting
  11. Express.js API Wrapper
  12. Praktiske eksempler

Kom i gang

1. Registrer deg for API-nøkkel

Først må du registrere deg for å få en API-nøkkel:

  1. Gå til https://kassal.app/api
  2. Opprett en konto eller logg inn
  3. Generer en ny API-nøkkel
  4. Lagre nøkkelen trygt - du får ikke se den igjen!

2. Installer nødvendige pakker

bash
# Med npm
npm install axios@^1.6.0 dotenv@^16.3.0 helmet@^7.1.0 cors@^2.8.5 express@^4.18.0 express-validator@^7.0.0 winston@^3.11.0

# Med yarn
yarn add axios@^1.6.0 dotenv@^16.3.0 helmet@^7.1.0 cors@^2.8.5 express@^4.18.0 express-validator@^7.0.0 winston@^3.11.0

# For TypeScript-støtte (anbefalt versioner)
npm install --save-dev @types/node@^20.0.0 @types/express@^4.17.0 @types/cors@^2.8.0 typescript@^5.2.0 ts-node@^10.9.0

3. Miljøvariabler

Opprett en .env fil i rotmappen:

env
# Kassalapp API konfigurasjon
KASSALAPP_API_KEY=din_api_nøkkel_her
KASSALAPP_API_URL=https://kassal.app/api/v1

# Server konfigurasjon
NODE_ENV=development
PORT=3000

# Sikkerhet
ALLOWED_ORIGINS=http://localhost:3000,https://yourdomain.com

# Rate limiting (forespørsler per minutt)
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_REQUESTS=60

Autentisering

All API-kommunikasjon krever autentisering via Bearer token i Authorization-headeren:

javascript
// config.js
import dotenv from 'dotenv';
dotenv.config();

export const config = {
  apiKey: process.env.KASSALAPP_API_KEY,
  apiUrl: process.env.KASSALAPP_API_URL || 'https://kassal.app/api/v1',
  rateLimit: {
    windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 60000,
    maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 60,
  },
  server: {
    port: parseInt(process.env.PORT) || 3000,
    allowedOrigins: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],
  },
};

// Valider nødvendige miljøvariabler
if (!config.apiKey) {
  throw new Error('KASSALAPP_API_KEY er ikke satt i miljøvariabler');
}

Oppsett med Axios

Grunnleggende Axios-konfigurasjon

javascript
// api-client.js
import axios from 'axios';
import { config } from './config.js';

const apiClient = axios.create({
  baseURL: config.apiUrl,
  headers: {
    'Authorization': `Bearer ${config.apiKey}`,
    'Accept': 'application/json',
    'Content-Type': 'application/json',
    'User-Agent': 'KassalappNodeClient/1.0.0',
  },
  timeout: 10000, // 10 sekunder timeout
  // Forbedret retry-konfigurasjon
  validateStatus: (status) => status >= 200 && status < 300,
});

// Request interceptor for logging
apiClient.interceptors.request.use(
  (request) => {
    console.log(`🚀 ${request.method?.toUpperCase()} ${request.url}`);
    return request;
  },
  (error) => {
    console.error('❌ Request error:', error);
    return Promise.reject(error);
  }
);

// Response interceptor for feilhåndtering
apiClient.interceptors.response.use(
  (response) => {
    console.log(`✅ ${response.status} ${response.config.method?.toUpperCase()} ${response.config.url}`);
    return response;
  },
  (error) => {
    if (error.response?.status === 429) {
      const retryAfter = error.response.headers['retry-after'];
      const waitTime = retryAfter ? parseInt(retryAfter) : 60;
      console.error(`⚠️ Rate limit nådd. Vent ${waitTime} sekunder før neste forespørsel.`);
    } else if (error.response?.status === 401) {
      console.error('🔒 API-nøkkel ugyldig eller utløpt');
    } else if (error.response?.status >= 500) {
      console.error('🚨 Server feil - prøv igjen senere');
    } else if (error.code === 'ECONNABORTED') {
      console.error('⏰ Forespørsel timed out');
    }
    return Promise.reject(error);
  }
);

export default apiClient;

Alternativ med moderne fetch API

javascript
// api-client-fetch.js (Node.js 18+)
import { config } from './config.js';

// Moderne fetch-basert klient (krever Node.js 18+)
export async function apiRequest(endpoint, params = {}, options = {}) {
  const url = new URL(`${config.apiUrl}${endpoint}`);
  
  // Legg til query parameters
  Object.keys(params).forEach(key => {
    if (params[key] !== undefined && params[key] !== null) {
      url.searchParams.append(key, String(params[key]));
    }
  });
  
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), options.timeout || 10000);
  
  try {
    const response = await fetch(url.toString(), {
      headers: {
        'Authorization': `Bearer ${config.apiKey}`,
        'Accept': 'application/json',
        'User-Agent': 'KassalappNodeClient/1.0.0',
        ...options.headers
      },
      method: options.method || 'GET',
      body: options.body ? JSON.stringify(options.body) : undefined,
      signal: controller.signal,
    });
    
    clearTimeout(timeoutId);
    
    if (!response.ok) {
      const errorBody = await response.text();
      throw new Error(`HTTP ${response.status}: ${response.statusText}\n${errorBody}`);
    }
    
    return await response.json();
  } catch (error) {
    clearTimeout(timeoutId);
    if (error.name === 'AbortError') {
      throw new Error('Request timed out');
    }
    throw error;
  }
}

TypeScript-grensesnitt

Type-definisjoner for API-responser

typescript
// types/kassalapp.ts

// Enums for better type safety
export enum WeightUnit {
  KILOGRAM = 'kg',
  GRAM = 'g',
  LITER = 'l',
  MILLILITER = 'ml',
  PIECE = 'stk',
}

export enum SortOption {
  PRICE_ASC = 'price_asc',
  PRICE_DESC = 'price_desc',
  NAME_ASC = 'name_asc',
  NAME_DESC = 'name_desc',
  DISCOUNT_ASC = 'discount_asc',
  DISCOUNT_DESC = 'discount_desc',
}

export interface Store {
  readonly id: number;
  readonly name: string;
  readonly url: string;
  readonly logo: string;
}

export interface Product {
  id: number;
  name: string;
  brand: string | null;
  vendor: string | null;
  description: string | null;
  ingredients: string | null;
  url: string;
  image: string | null;
  store: Store;
  current_price: number | null;
  current_unit_price: number | null;
  weight: number | null;
  weight_unit: string | null;
  price_history: PriceHistory[];
  barcode: Barcode | null;
  category: Category | null;
  allergens: Allergen[];
  nutrition: Nutrition | null;
  labels: Label[];
  created_at: string;
  updated_at: string;
}

export interface PriceHistory {
  price: number;
  date: string;
}

export interface Barcode {
  ean: string;
  products: Product[];
}

export interface Category {
  id: number;
  name: string;
  depth: number;
}

export interface Allergen {
  code: string;
  display_name: string;
  contains: 'YES' | 'NO' | 'MAY_CONTAIN' | 'UNKNOWN';
}

export interface Nutrition {
  display_name: string;
  amount: number;
  unit: string;
  code: string;
}

export interface Label {
  name: string;
  display_name: string;
  description: string;
  organization: string;
}

export interface PhysicalStore {
  id: string;
  group: string;
  name: string;
  address: string;
  phone: string | null;
  email: string | null;
  fax: string | null;
  position: {
    lat: number;
    lng: number;
  };
  opening_hours: OpeningHours;
  logo: string;
  website: string;
  detail_url: string | null;
  store: Store;
}

export interface OpeningHours {
  [day: string]: {
    open: string;
    close: string;
    closed: boolean;
  };
}

export interface ApiResponse<T> {
  data: T[];
  links?: {
    first: string;
    last: string;
    prev: string | null;
    next: string | null;
  };
  meta?: {
    current_page: number;
    from: number;
    last_page: number;
    per_page: number;
    to: number;
    total: number;
  };
}

export interface ApiError {
  message: string;
  errors?: Record<string, string[]>;
  status: number;
}

export class KassalappApiError extends Error {
  constructor(
    public status: number,
    public errors?: Record<string, string[]>,
    message?: string
  ) {
    super(message || 'API Error');
    this.name = 'KassalappApiError';
  }
}

TypeScript API-klient

typescript
// kassalapp-client.ts
import axios, { AxiosInstance } from 'axios';
import { Product, PhysicalStore, ApiResponse, Category, Label } from './types/kassalapp';

export class KassalappClient {
  private client: AxiosInstance;
  
  constructor(apiKey: string) {
    this.client = axios.create({
      baseURL: 'https://kassal.app/api/v1',
      headers: {
        'Authorization': `Bearer ${apiKey}`,
        'Accept': 'application/json',
      },
    });
  }
  
  // Søk etter produkter
  async searchProducts(params: {
    search?: string;
    category?: string;
    category_id?: number;
    brand?: string;
    vendor?: string;
    price_min?: number;
    price_max?: number;
    sort?: 'price_asc' | 'price_desc' | 'name_asc' | 'name_desc' | 'date_asc' | 'date_desc';
    page?: number;
    size?: number;
    unique?: boolean;
    exclude_without_ean?: boolean;
  } = {}): Promise<ApiResponse<Product>> {
    const response = await this.client.get('/products', { params });
    return response.data;
  }
  
  // Hent produkt via ID
  async getProductById(id: number): Promise<Product> {
    const response = await this.client.get(`/products/id/${id}`);
    return response.data.data;
  }
  
  // Hent produkt via EAN/strekkode
  async getProductByEan(ean: string): Promise<Product[]> {
    const response = await this.client.get(`/products/ean/${ean}`);
    return response.data.data;
  }
  
  // Hent produkt via URL
  async getProductByUrl(url: string): Promise<Product> {
    const response = await this.client.get('/products/find-by-url/single', {
      params: { url }
    });
    return response.data.data;
  }
  
  // Sammenlign priser via URL
  async comparePricesByUrl(url: string): Promise<Product[]> {
    const response = await this.client.get('/products/find-by-url/compare', {
      params: { url }
    });
    return response.data.data;
  }
  
  // Hent butikklokasjoner
  async getPhysicalStores(params: {
    search?: string;
    lat?: number;
    lng?: number;
    km?: number;
    group?: string;
    page?: number;
    size?: number;
  } = {}): Promise<ApiResponse<PhysicalStore>> {
    const response = await this.client.get('/physical-stores', { params });
    return response.data;
  }
  
  // Hent kategorier
  async getCategories(): Promise<Category[]> {
    const response = await this.client.get('/categories');
    return response.data.data;
  }
  
  // Hent merker/labels
  async getLabels(): Promise<Label[]> {
    const response = await this.client.get('/labels');
    return response.data.data;
  }
}

Produktsøk

Enkelt søk med async/await

javascript
// search-products.js
import apiClient from './api-client.js';

async function searchProducts(searchTerm) {
  try {
    const response = await apiClient.get('/products', {
      params: {
        search: searchTerm,
        size: 20, // Maks 100
      }
    });
    
    const { data, meta } = response.data;
    
    console.log(`Fant ${meta.total} produkter for "${searchTerm}"`);
    
    data.forEach(product => {
      console.log(`
        📦 ${product.name}
        💰 ${product.current_price ? `${product.current_price} kr` : 'Pris ikke tilgjengelig'}
        🏪 ${product.store.name}
        🔗 ${product.url}
      `);
    });
    
    return data;
  } catch (error) {
    console.error('Feil ved søk:', error.message);
    throw error;
  }
}

// Eksempel bruk
searchProducts('grandiosa').then(products => {
  console.log(`Hentet ${products.length} produkter`);
});

Avansert søk med filtrering

javascript
// advanced-search.js
import apiClient from './api-client.js';

class ProductSearch {
  constructor() {
    this.results = [];
    this.currentPage = 1;
    this.totalPages = 1;
  }
  
  async search(options = {}) {
    const defaultOptions = {
      page: 1,
      size: 20,
      sort: 'price_asc',
      unique: false,
      exclude_without_ean: false,
    };
    
    const params = { ...defaultOptions, ...options };
    
    try {
      const response = await apiClient.get('/products', { params });
      const { data, meta } = response.data;
      
      this.results = data;
      this.currentPage = meta.current_page;
      this.totalPages = meta.last_page;
      
      return {
        products: data,
        hasMore: this.currentPage < this.totalPages,
        total: meta.total,
        page: this.currentPage,
        pages: this.totalPages,
      };
    } catch (error) {
      console.error('Søkefeil:', error);
      throw error;
    }
  }
  
  async nextPage(options = {}) {
    if (this.currentPage >= this.totalPages) {
      console.log('Ingen flere sider');
      return null;
    }
    
    return this.search({
      ...options,
      page: this.currentPage + 1,
    });
  }
  
  // Finn billigste produkt
  async findCheapest(searchTerm) {
    const result = await this.search({
      search: searchTerm,
      sort: 'price_asc',
      size: 10, // Hent flere for å finne faktisk billigste
    });
    
    // Filter ut produkter uten pris og finn billigste
    const productsWithPrice = result.products.filter(p => p.current_price !== null && p.current_price > 0);
    
    if (productsWithPrice.length === 0) {
      throw new Error(`Ingen produkter med pris funnet for: ${searchTerm}`);
    }
    
    return productsWithPrice.reduce((cheapest, current) => 
      current.current_price < cheapest.current_price ? current : cheapest
    );
  }
  
  // Finn produkter innenfor prisområde
  async findInPriceRange(searchTerm, minPrice, maxPrice) {
    return this.search({
      search: searchTerm,
      price_min: minPrice,
      price_max: maxPrice,
      sort: 'price_asc',
    });
  }
}

// Eksempel bruk
const searcher = new ProductSearch();

// Finn billigste melk
searcher.findCheapest('melk').then(product => {
  if (product) {
    console.log(`Billigste melk: ${product.name} - ${product.current_price} kr`);
  }
});

// Finn produkter mellom 10 og 50 kr
searcher.findInPriceRange('pizza', 10, 50).then(result => {
  console.log(`Fant ${result.total} pizzaer mellom 10-50 kr`);
});

Filtrering og sortering

Kategorifiltrering

javascript
// category-filter.js
import apiClient from './api-client.js';

class CategoryFilter {
  constructor() {
    this.categories = null;
  }
  
  // Hent alle kategorier
  async fetchCategories() {
    try {
      const response = await apiClient.get('/categories');
      this.categories = response.data.data;
      return this.categories;
    } catch (error) {
      console.error('Kunne ikke hente kategorier:', error);
      throw error;
    }
  }
  
  // Finn kategori basert på navn
  findCategoryByName(name) {
    if (!this.categories) {
      throw new Error('Kategorier ikke lastet. Kjør fetchCategories() først.');
    }
    
    return this.categories.find(cat => 
      cat.name.toLowerCase().includes(name.toLowerCase())
    );
  }
  
  // Hent produkter i kategori
  async getProductsInCategory(categoryId, options = {}) {
    try {
      const response = await apiClient.get('/products', {
        params: {
          category_id: categoryId,
          size: options.size || 20,
          page: options.page || 1,
          sort: options.sort || 'price_asc',
        }
      });
      
      return response.data;
    } catch (error) {
      console.error('Feil ved henting av kategoriprodukter:', error);
      throw error;
    }
  }
  
  // Hent produkter via kategorinavn
  async searchByCategory(categoryName, searchTerm = '') {
    try {
      const response = await apiClient.get('/products', {
        params: {
          category: categoryName,
          search: searchTerm,
          size: 50,
        }
      });
      
      return response.data;
    } catch (error) {
      console.error('Kategorisøk feilet:', error);
      throw error;
    }
  }
}

// Eksempel bruk
async function eksempelKategoriSøk() {
  const filter = new CategoryFilter();
  
  // Hent alle kategorier
  const categories = await filter.fetchCategories();
  console.log(`Fant ${categories.length} kategorier`);
  
  // Finn meieriprodukter
  const meieri = filter.findCategoryByName('meieri');
  if (meieri) {
    console.log(`Meieri-kategori ID: ${meieri.id}`);
    
    // Hent produkter i meierikategorien
    const result = await filter.getProductsInCategory(meieri.id, {
      sort: 'price_asc',
      size: 10,
    });
    
    console.log(`\nBilligste meieriprodukter:`);
    result.data.forEach((product, index) => {
      console.log(`${index + 1}. ${product.name} - ${product.current_price} kr`);
    });
  }
  
  // Søk direkte med kategorinavn
  const brødProdukter = await filter.searchByCategory('brød', 'grovbrød');
  console.log(`\nFant ${brødProdukter.meta.total} grovbrød`);
}

eksempelKategoriSøk();

Merke- og leverandørfiltrering

javascript
// brand-vendor-filter.js
import apiClient from './api-client.js';

class BrandVendorFilter {
  // Søk produkter fra spesifikk merkevare
  async searchByBrand(brand, options = {}) {
    try {
      const response = await apiClient.get('/products', {
        params: {
          brand: brand,
          search: options.search || '',
          size: options.size || 20,
          sort: options.sort || 'name_asc',
        }
      });
      
      return response.data;
    } catch (error) {
      console.error(`Feil ved søk etter ${brand} produkter:`, error);
      throw error;
    }
  }
  
  // Søk produkter fra spesifikk leverandør
  async searchByVendor(vendor, options = {}) {
    try {
      const response = await apiClient.get('/products', {
        params: {
          vendor: vendor,
          search: options.search || '',
          size: options.size || 20,
          sort: options.sort || 'price_asc',
        }
      });
      
      return response.data;
    } catch (error) {
      console.error(`Feil ved søk etter ${vendor} produkter:`, error);
      throw error;
    }
  }
  
  // Sammenlign priser for samme merkevare på tvers av butikker
  async compareBrandPrices(brand, productSearch) {
    try {
      const response = await apiClient.get('/products', {
        params: {
          brand: brand,
          search: productSearch,
          size: 100,
          sort: 'price_asc',
        }
      });
      
      const products = response.data.data;
      
      // Grupper etter EAN for å finne samme produkt i ulike butikker
      const grouped = {};
      
      products.forEach(product => {
        const ean = product.barcode?.ean || 'no-ean';
        if (!grouped[ean]) {
          grouped[ean] = {
            name: product.name,
            products: [],
          };
        }
        grouped[ean].products.push({
          store: product.store.name,
          price: product.current_price,
          url: product.url,
        });
      });
      
      return grouped;
    } catch (error) {
      console.error('Feil ved prissammenligning:', error);
      throw error;
    }
  }
}

// Eksempel bruk
async function merkevareEksempel() {
  const filter = new BrandVendorFilter();
  
  // Finn alle Tine-produkter
  const tineProdukter = await filter.searchByBrand('Tine', {
    sort: 'price_asc',
    size: 10,
  });
  
  console.log('Billigste Tine-produkter:');
  tineProdukter.data.forEach(product => {
    console.log(`- ${product.name}: ${product.current_price} kr (${product.store.name})`);
  });
  
  // Sammenlign priser for Tine melk
  const prissammenligning = await filter.compareBrandPrices('Tine', 'melk');
  
  console.log('\nPrissammenligning Tine melk:');
  Object.values(prissammenligning).forEach(group => {
    console.log(`\n${group.name}:`);
    group.products
      .sort((a, b) => (a.price || 999) - (b.price || 999))
      .forEach(p => {
        console.log(`  ${p.store}: ${p.price ? p.price + ' kr' : 'Ikke tilgjengelig'}`);
      });
  });
}

merkevareEksempel();

Strekkodeskanning

EAN/Barcode oppslag

javascript
// barcode-scanner.js
import apiClient from './api-client.js';

class BarcodeScanner {
  // Slå opp produkt via strekkode
  async scanBarcode(ean) {
    // Valider EAN-format (8 eller 13 siffer)
    if (!/^(\d{8}|\d{13})$/.test(ean)) {
      throw new Error('Ugyldig EAN-format. Må være 8 eller 13 siffer.');
    }
    
    try {
      const response = await apiClient.get(`/products/ean/${ean}`);
      const products = response.data.data;
      
      if (products.length === 0) {
        return {
          found: false,
          message: `Ingen produkter funnet for EAN: ${ean}`,
        };
      }
      
      // Finn billigste alternativ
      const cheapest = products.reduce((min, p) => 
        (p.current_price && (!min.current_price || p.current_price < min.current_price)) ? p : min
      , products[0]);
      
      // Finn alle butikker som selger produktet
      const stores = products.map(p => ({
        name: p.store.name,
        price: p.current_price,
        url: p.url,
        inStock: p.current_price !== null,
      }));
      
      return {
        found: true,
        product: products[0], // Første produkt for generell info
        cheapest: cheapest,
        stores: stores,
        totalStores: stores.length,
      };
    } catch (error) {
      console.error('Feil ved strekkodeskanning:', error);
      throw error;
    }
  }
  
  // Batch-skanning av flere strekkoder
  async scanMultiple(eanList) {
    const results = [];
    
    for (const ean of eanList) {
      try {
        const result = await this.scanBarcode(ean);
        results.push({ ean, ...result });
        
        // Vent litt mellom forespørsler for å unngå rate limiting
        await this.delay(100);
      } catch (error) {
        results.push({
          ean,
          found: false,
          error: error.message,
        });
      }
    }
    
    return results;
  }
  
  // Hjelpefunksjon for forsinkelse
  delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
  
  // Generer handleliste med priser
  async generateShoppingList(eanList, preferredStore = null) {
    const items = [];
    let totalPrice = 0;
    
    for (const ean of eanList) {
      const result = await this.scanBarcode(ean);
      
      if (result.found) {
        let selectedProduct;
        
        if (preferredStore) {
          // Finn produkt fra foretrukket butikk
          selectedProduct = result.stores.find(s => 
            s.name.toLowerCase().includes(preferredStore.toLowerCase())
          );
        }
        
        // Fallback til billigste hvis ikke funnet i foretrukket butikk
        if (!selectedProduct) {
          selectedProduct = result.cheapest;
        }
        
        items.push({
          name: result.product.name,
          ean: ean,
          price: selectedProduct.current_price,
          store: selectedProduct.store?.name || 'Ukjent',
          url: selectedProduct.url,
        });
        
        if (selectedProduct.current_price) {
          totalPrice += selectedProduct.current_price;
        }
      }
      
      await this.delay(100);
    }
    
    return {
      items,
      totalPrice,
      itemCount: items.length,
    };
  }
}

// Eksempel bruk
async function strekkodeEksempel() {
  const scanner = new BarcodeScanner();
  
  // Skann enkelt produkt
  console.log('Skanner strekkode 7039010576963 (Grandiosa)...');
  const result = await scanner.scanBarcode('7039010576963');
  
  if (result.found) {
    console.log(`\n✅ Produkt funnet: ${result.product.name}`);
    console.log(`Billigste pris: ${result.cheapest.current_price} kr hos ${result.cheapest.store.name}`);
    console.log(`Tilgjengelig i ${result.totalStores} butikker:`);
    
    result.stores
      .sort((a, b) => (a.price || 999) - (b.price || 999))
      .forEach(store => {
        const status = store.inStock ? '✅' : '❌';
        const price = store.price ? `${store.price} kr` : 'Utsolgt';
        console.log(`  ${status} ${store.name}: ${price}`);
      });
  }
  
  // Generer handleliste
  console.log('\n📝 Genererer handleliste...');
  const handleliste = await scanner.generateShoppingList([
    '7039010576963', // Grandiosa
    '7032069715253', // Tine Lettmelk
    '7037203778930', // Norvegia
  ], 'Rema');
  
  console.log('\nHandleliste:');
  handleliste.items.forEach((item, index) => {
    console.log(`${index + 1}. ${item.name} - ${item.price} kr (${item.store})`);
  });
  console.log(`\nTotalpris: ${handleliste.totalPrice.toFixed(2)} kr`);
}

strekkodeEksempel();

Butikklokasjoner

Finn nærmeste butikker

javascript
// store-locator.js
import apiClient from './api-client.js';

class StoreLocator {
  // Finn butikker nær en posisjon
  async findNearbyStores(lat, lng, radius = 5) {
    try {
      const response = await apiClient.get('/physical-stores', {
        params: {
          lat: lat,
          lng: lng,
          km: radius,
          size: 50,
        }
      });
      
      const stores = response.data.data;
      
      // Beregn avstand (grov tilnærming)
      const storesWithDistance = stores.map(store => {
        const distance = this.calculateDistance(
          lat, lng,
          store.position.lat, store.position.lng
        );
        
        return {
          ...store,
          distance: distance,
        };
      });
      
      // Sorter etter avstand
      storesWithDistance.sort((a, b) => a.distance - b.distance);
      
      return storesWithDistance;
    } catch (error) {
      console.error('Feil ved søk etter butikker:', error);
      throw error;
    }
  }
  
  // Finn butikker fra spesifikk kjede
  async findChainStores(chain, options = {}) {
    try {
      const response = await apiClient.get('/physical-stores', {
        params: {
          group: chain,
          search: options.search || '',
          size: options.size || 20,
          page: options.page || 1,
        }
      });
      
      return response.data;
    } catch (error) {
      console.error(`Feil ved søk etter ${chain} butikker:`, error);
      throw error;
    }
  }
  
  // Søk butikker etter navn eller adresse
  async searchStores(searchTerm) {
    try {
      const response = await apiClient.get('/physical-stores', {
        params: {
          search: searchTerm,
          size: 20,
        }
      });
      
      return response.data.data;
    } catch (error) {
      console.error('Feil ved butikksøk:', error);
      throw error;
    }
  }
  
  // Hent detaljer for spesifikk butikk
  async getStoreDetails(storeId) {
    try {
      const response = await apiClient.get(`/physical-stores/${storeId}`);
      return response.data.data;
    } catch (error) {
      console.error('Feil ved henting av butikkdetaljer:', error);
      throw error;
    }
  }
  
  // Sjekk om butikk er åpen nå
  isStoreOpen(store) {
    const now = new Date();
    const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
    const today = dayNames[now.getDay()];
    
    const todayHours = store.opening_hours[today];
    if (!todayHours || todayHours.closed) {
      return false;
    }
    
    const currentTime = now.getHours() * 60 + now.getMinutes();
    const [openHour, openMin] = todayHours.open.split(':').map(Number);
    const [closeHour, closeMin] = todayHours.close.split(':').map(Number);
    
    const openTime = openHour * 60 + openMin;
    const closeTime = closeHour * 60 + closeMin;
    
    return currentTime >= openTime && currentTime <= closeTime;
  }
  
  // Beregn avstand mellom to punkter (Haversine formel)
  calculateDistance(lat1, lon1, lat2, lon2) {
    const R = 6371; // Jordens radius i kilometer
    const dLat = this.toRad(lat2 - lat1);
    const dLon = this.toRad(lon2 - lon1);
    const a = 
      Math.sin(dLat/2) * Math.sin(dLat/2) +
      Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * 
      Math.sin(dLon/2) * Math.sin(dLon/2);
    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
    return R * c;
  }
  
  toRad(deg) {
    return deg * (Math.PI/180);
  }
  
  // Finn beste handletur (butikker med flest av ønskede produkter)
  async findBestShoppingRoute(position, productEans) {
    const nearbyStores = await this.findNearbyStores(position.lat, position.lng, 10);
    const storeAvailability = {};
    
    // Sjekk hvilke produkter hver butikk har
    for (const ean of productEans) {
      const response = await apiClient.get(`/products/ean/${ean}`);
      const products = response.data.data;
      
      products.forEach(product => {
        const storeGroup = this.getStoreGroup(product.store.name);
        if (!storeAvailability[storeGroup]) {
          storeAvailability[storeGroup] = {
            products: [],
            totalPrice: 0,
          };
        }
        
        storeAvailability[storeGroup].products.push({
          name: product.name,
          price: product.current_price,
          ean: ean,
        });
        
        if (product.current_price) {
          storeAvailability[storeGroup].totalPrice += product.current_price;
        }
      });
      
      await this.delay(100);
    }
    
    // Match med nærliggende butikker
    const recommendations = nearbyStores
      .filter(store => storeAvailability[store.group])
      .map(store => ({
        store: store,
        availability: storeAvailability[store.group],
        distance: store.distance,
        isOpen: this.isStoreOpen(store),
      }))
      .sort((a, b) => {
        // Prioriter butikker med flest produkter, deretter avstand
        const productDiff = b.availability.products.length - a.availability.products.length;
        if (productDiff !== 0) return productDiff;
        return a.distance - b.distance;
      });
    
    return recommendations;
  }
  
  getStoreGroup(storeName) {
    // Map butikknavn til gruppe
    const mappings = {
      'rema': 'rema_1000',
      'kiwi': 'kiwi',
      'meny': 'meny',
      'spar': 'spar',
      'joker': 'joker',
      'coop': 'coop',
      'extra': 'coop_extra',
      'prix': 'coop_prix',
      'mega': 'coop_mega',
    };
    
    const lower = storeName.toLowerCase();
    for (const [key, value] of Object.entries(mappings)) {
      if (lower.includes(key)) return value;
    }
    return storeName.toLowerCase().replace(/\s+/g, '_');
  }
  
  delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

// Eksempel bruk
async function butikkEksempel() {
  const locator = new StoreLocator();
  
  // Oslo sentrum koordinater
  const osloPosition = {
    lat: 59.9139,
    lng: 10.7522,
  };
  
  console.log('🗺️ Finner butikker nær Oslo sentrum...\n');
  const nearbyStores = await locator.findNearbyStores(
    osloPosition.lat,
    osloPosition.lng,
    3 // 3 km radius
  );
  
  console.log('Nærmeste butikker:');
  nearbyStores.slice(0, 5).forEach((store, index) => {
    const openStatus = locator.isStoreOpen(store) ? '🟢 Åpen' : '🔴 Stengt';
    console.log(`${index + 1}. ${store.name} (${store.group})`);
    console.log(`   📍 ${store.address}`);
    console.log(`   📏 ${store.distance.toFixed(2)} km unna`);
    console.log(`   ${openStatus}`);
    console.log('');
  });
  
  // Søk etter REMA 1000 butikker
  console.log('🔍 Søker etter REMA 1000 butikker...\n');
  const remaStores = await locator.findChainStores('rema_1000', { size: 5 });
  
  console.log(`Fant ${remaStores.meta.total} REMA 1000 butikker totalt`);
  console.log('Første 5:');
  remaStores.data.forEach(store => {
    console.log(`- ${store.name}, ${store.address}`);
  });
}

butikkEksempel();

Feilhåndtering

Robust feilhåndtering med retry-logikk

javascript
// error-handler.js
import apiClient from './api-client.js';

class ApiErrorHandler {
  constructor(maxRetries = 3, retryDelay = 1000) {
    this.maxRetries = maxRetries;
    this.retryDelay = retryDelay;
  }
  
  // Wrapper-funksjon med retry-logikk
  async executeWithRetry(fn, retries = 0) {
    try {
      return await fn();
    } catch (error) {
      const shouldRetry = this.shouldRetry(error, retries);
      
      if (shouldRetry) {
        const delay = this.calculateDelay(retries);
        console.log(`⏳ Forsøker på nytt om ${delay/1000} sekunder... (forsøk ${retries + 1}/${this.maxRetries})`);
        
        await this.delay(delay);
        return this.executeWithRetry(fn, retries + 1);
      }
      
      throw this.enhanceError(error);
    }
  }
  
  // Avgjør om vi skal prøve på nytt
  shouldRetry(error, retries) {
    if (retries >= this.maxRetries) return false;
    
    const status = error.response?.status;
    
    // Retry på følgende statuskoder
    const retryableStatuses = [
      408, // Request Timeout
      429, // Too Many Requests
      500, // Internal Server Error
      502, // Bad Gateway
      503, // Service Unavailable
      504, // Gateway Timeout
    ];
    
    // Retry også på nettverksfeil
    if (!status && error.code === 'ECONNABORTED') return true;
    if (!status && error.message?.includes('Network Error')) return true;
    
    return retryableStatuses.includes(status);
  }
  
  // Beregn forsinkelse med eksponentiell backoff
  calculateDelay(retries) {
    return Math.min(this.retryDelay * Math.pow(2, retries), 30000); // Maks 30 sekunder
  }
  
  // Forbedre feilmeldinger
  enhanceError(error) {
    const status = error.response?.status;
    const message = error.response?.data?.message || error.message;
    
    const errorMessages = {
      400: 'Ugyldig forespørsel. Sjekk parameterne dine.',
      401: 'Autentisering feilet. Sjekk API-nøkkelen din.',
      403: 'Ingen tilgang. Du har ikke tillatelse til denne ressursen.',
      404: 'Ressurs ikke funnet.',
      429: 'For mange forespørsler. Du har nådd rate limit.',
      500: 'Serverfeil. Prøv igjen senere.',
      502: 'Gateway-feil. API-et er midlertidig utilgjengelig.',
      503: 'Tjenesten er utilgjengelig. Prøv igjen senere.',
    };
    
    const enhancedError = new Error(errorMessages[status] || message);
    enhancedError.status = status;
    enhancedError.originalError = error;
    
    return enhancedError;
  }
  
  delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
  
  // Logg feil til fil eller ekstern tjeneste
  async logError(error, context = {}) {
    const errorLog = {
      timestamp: new Date().toISOString(),
      message: error.message,
      status: error.status,
      context: context,
      stack: error.stack,
    };
    
    // Her kan du sende til logging-tjeneste som Sentry, LogRocket, etc.
    console.error('📝 Error logged:', errorLog);
    
    // Lagre til lokal fil (valgfritt)
    try {
      const fs = await import('fs/promises');
      await fs.appendFile(
        'api-errors.log',
        JSON.stringify(errorLog) + '\n'
      );
    } catch (writeError) {
      console.error('Kunne ikke skrive til loggfil:', writeError);
    }
  }
}

// Wrapper-klasse for API-klient med feilhåndtering
class RobustApiClient {
  constructor() {
    this.errorHandler = new ApiErrorHandler();
  }
  
  async searchProducts(params) {
    return this.errorHandler.executeWithRetry(async () => {
      const response = await apiClient.get('/products', { params });
      return response.data;
    });
  }
  
  async getProductByEan(ean) {
    return this.errorHandler.executeWithRetry(async () => {
      const response = await apiClient.get(`/products/ean/${ean}`);
      
      if (response.data.data.length === 0) {
        throw new Error(`Ingen produkter funnet for EAN: ${ean}`);
      }
      
      return response.data.data;
    });
  }
  
  async getStores(params) {
    return this.errorHandler.executeWithRetry(async () => {
      const response = await apiClient.get('/physical-stores', { params });
      return response.data;
    });
  }
}

// Eksempel bruk med feilhåndtering
async function robustEksempel() {
  const client = new RobustApiClient();
  
  try {
    // Dette vil automatisk prøve på nytt ved feil
    const products = await client.searchProducts({
      search: 'melk',
      size: 10,
    });
    
    console.log(`Fant ${products.meta.total} produkter`);
  } catch (error) {
    console.error('Kritisk feil:', error.message);
    
    // Logg feilen for senere analyse
    await client.errorHandler.logError(error, {
      action: 'searchProducts',
      params: { search: 'melk', size: 10 },
    });
  }
  
  // Håndter manglende produkt
  try {
    const products = await client.getProductByEan('9999999999999'); // Ugyldig EAN
  } catch (error) {
    console.log('⚠️ Produkt ikke funnet:', error.message);
  }
}

robustEksempel();

Rate Limiting

Implementer rate limiting og køhåndtering

javascript
// rate-limiter.js
import apiClient from './api-client.js';

class RateLimiter {
  constructor(maxRequests = 100, windowMs = 60000) {
    this.maxRequests = maxRequests;
    this.windowMs = windowMs;
    this.requests = [];
    this.queue = [];
    this.processing = false;
  }
  
  // Sjekk om vi kan gjøre en forespørsel nå
  canMakeRequest() {
    const now = Date.now();
    
    // Fjern gamle forespørsler utenfor tidsvinduet
    this.requests = this.requests.filter(time => now - time < this.windowMs);
    
    return this.requests.length < this.maxRequests;
  }
  
  // Vent til vi kan gjøre neste forespørsel
  async waitForSlot() {
    while (!this.canMakeRequest()) {
      const oldestRequest = this.requests[0];
      const waitTime = this.windowMs - (Date.now() - oldestRequest) + 100;
      
      console.log(`⏳ Rate limit nådd. Venter ${(waitTime/1000).toFixed(1)} sekunder...`);
      await this.delay(waitTime);
    }
  }
  
  // Utfør forespørsel med rate limiting
  async execute(fn) {
    await this.waitForSlot();
    
    this.requests.push(Date.now());
    
    try {
      return await fn();
    } catch (error) {
      // Hvis vi får 429, vent ekstra lenge
      if (error.response?.status === 429) {
        const retryAfter = error.response.headers['retry-after'];
        const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : 60000;
        
        console.log(`🛑 Rate limit truffet. Venter ${waitTime/1000} sekunder...`);
        await this.delay(waitTime);
        
        // Prøv på nytt
        return this.execute(fn);
      }
      
      throw error;
    }
  }
  
  // Købasert behandling av forespørsler
  async addToQueue(fn, priority = 0) {
    return new Promise((resolve, reject) => {
      this.queue.push({ fn, priority, resolve, reject });
      this.queue.sort((a, b) => b.priority - a.priority); // Høyere prioritet først
      
      if (!this.processing) {
        this.processQueue();
      }
    });
  }
  
  async processQueue() {
    this.processing = true;
    
    while (this.queue.length > 0) {
      const { fn, resolve, reject } = this.queue.shift();
      
      try {
        const result = await this.execute(fn);
        resolve(result);
      } catch (error) {
        reject(error);
      }
    }
    
    this.processing = false;
  }
  
  delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
  
  // Hent gjenstående forespørsler
  getRemainingRequests() {
    const now = Date.now();
    this.requests = this.requests.filter(time => now - time < this.windowMs);
    return this.maxRequests - this.requests.length;
  }
  
  // Reset rate limiter
  reset() {
    this.requests = [];
    this.queue = [];
  }
}

// Batch-prosessering med rate limiting
class BatchProcessor {
  constructor(rateLimiter) {
    this.rateLimiter = rateLimiter;
  }
  
  // Prosesser mange EAN-er i batch
  async processBatch(eans, batchSize = 10) {
    const results = [];
    const batches = this.createBatches(eans, batchSize);
    
    console.log(`📦 Prosesserer ${eans.length} produkter i ${batches.length} batch...`);
    
    for (let i = 0; i < batches.length; i++) {
      const batch = batches[i];
      console.log(`\nBatch ${i + 1}/${batches.length}:`);
      
      const batchResults = await Promise.all(
        batch.map(ean => 
          this.rateLimiter.addToQueue(async () => {
            const response = await apiClient.get(`/products/ean/${ean}`);
            console.log(`  ✅ ${ean} hentet`);
            return { ean, products: response.data.data };
          })
        )
      );
      
      results.push(...batchResults);
      
      // Vis fremgang
      const remaining = this.rateLimiter.getRemainingRequests();
      console.log(`Gjenstående forespørsler denne perioden: ${remaining}`);
    }
    
    return results;
  }
  
  createBatches(items, batchSize) {
    const batches = [];
    for (let i = 0; i < items.length; i += batchSize) {
      batches.push(items.slice(i, i + batchSize));
    }
    return batches;
  }
  
  // Prosesser med parallelle forespørsler men respekter rate limit
  async processParallel(tasks, concurrency = 5) {
    const results = [];
    const executing = [];
    
    for (const task of tasks) {
      const promise = this.rateLimiter.execute(task).then(result => {
        executing.splice(executing.indexOf(promise), 1);
        return result;
      });
      
      results.push(promise);
      executing.push(promise);
      
      if (executing.length >= concurrency) {
        await Promise.race(executing);
      }
    }
    
    return Promise.all(results);
  }
}

// Eksempel bruk
async function rateLimitEksempel() {
  const limiter = new RateLimiter(100, 60000); // 100 req per minutt
  const processor = new BatchProcessor(limiter);
  
  // Prosesser mange produkter
  const eans = [
    '7039010576963', '7032069715253', '7037203778930',
    '7038010005458', '7311041019139', '7035620030123',
    '7090026220349', '7038010000737', '7032069715277',
    '7310865001801', '7311041013755', '7037203532117',
  ];
  
  const results = await processor.processBatch(eans, 3);
  
  console.log('\n📊 Resultater:');
  results.forEach(({ ean, products }) => {
    if (products.length > 0) {
      const cheapest = products.reduce((min, p) => 
        (p.current_price && (!min.current_price || p.current_price < min.current_price)) ? p : min
      );
      console.log(`${ean}: ${products[0].name} - Billigst: ${cheapest.current_price} kr`);
    }
  });
}

rateLimitEksempel();

Express.js API Wrapper

Komplett Express API wrapper for Kassalapp

javascript
// server.js
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import { body, param, query, validationResult } from 'express-validator';
import winston from 'winston';
import { KassalappClient } from './kassalapp-client.js';
import { RateLimiter } from './rate-limiter.js';
import dotenv from 'dotenv';

dotenv.config();

const app = express();
const port = process.env.PORT || 3000;

// Logger setup
const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' }),
  ],
});

if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.simple(),
  }));
}

// Security middleware - Enhanced configuration
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'", 'https:'],
      scriptSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", 'data:', 'https:'],
      connectSrc: ["'self'", 'https://kassal.app'],
      fontSrc: ["'self'", 'https:', 'data:'],
      objectSrc: ["'none'"],
      mediaSrc: ["'self'"],
      frameSrc: ["'none'"],
    },
  },
  crossOriginEmbedderPolicy: false, // For API-kall
}));

// CORS configuration
app.use(cors({
  origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
  credentials: true,
  optionsSuccessStatus: 200,
}));

app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));

// Validation middleware
const handleValidationErrors = (req, res, next) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({ errors: errors.array() });
  }
  next();
};

// Rate limiting for vårt eget API - Enhanced configuration
const limiter = rateLimit({
  windowMs: config.rateLimit.windowMs,
  max: config.rateLimit.maxRequests,
  message: {
    error: 'For mange forespørsler, prøv igjen senere.',
    retryAfter: Math.ceil(config.rateLimit.windowMs / 1000)
  },
  standardHeaders: true, // Return rate limit info in headers
  legacyHeaders: false, // Disable X-RateLimit-* headers
  skipSuccessfulRequests: false, // Count successful requests
  skipFailedRequests: false, // Count failed requests
  keyGenerator: (req) => req.ip, // Use IP for rate limit key
  handler: (req, res) => {
    logger.warn(`Rate limit exceeded for IP: ${req.ip} - Path: ${req.path}`);
    res.status(429).json({
      error: 'For mange forespørsler',
      retryAfter: Math.ceil(config.rateLimit.windowMs / 1000),
      limit: config.rateLimit.maxRequests,
      window: config.rateLimit.windowMs / 1000
    });
  },
});

app.use('/api/', limiter);

// Initialiser Kassalapp-klient
const kassalapp = new KassalappClient(process.env.KASSALAPP_API_KEY);
const rateLimiter = new RateLimiter(100, 60000);

// Cache for ofte brukte data
const cache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutter

// Hjelpefunksjon for caching
function getCached(key) {
  const cached = cache.get(key);
  if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
    return cached.data;
  }
  return null;
}

function setCache(key, data) {
  cache.set(key, { data, timestamp: Date.now() });
}

// API Endpoints

// Helsejekk
app.get('/health', (req, res) => {
  res.json({ status: 'OK', service: 'Kassalapp API Wrapper' });
});

// Søk produkter
app.get('/api/products/search', async (req, res) => {
  try {
    const { q, category, brand, minPrice, maxPrice, sort, page = 1, size = 20 } = req.query;
    
    if (!q) {
      return res.status(400).json({ error: 'Søkeord (q) er påkrevd' });
    }
    
    const cacheKey = `search:${JSON.stringify(req.query)}`;
    const cached = getCached(cacheKey);
    if (cached) {
      return res.json({ ...cached, cached: true });
    }
    
    const result = await rateLimiter.execute(async () => {
      return kassalapp.searchProducts({
        search: q,
        category,
        brand,
        price_min: minPrice ? parseFloat(minPrice) : undefined,
        price_max: maxPrice ? parseFloat(maxPrice) : undefined,
        sort,
        page: parseInt(page),
        size: Math.min(parseInt(size), 100),
      });
    });
    
    setCache(cacheKey, result);
    res.json(result);
  } catch (error) {
    console.error('Søkefeil:', error);
    res.status(500).json({ error: 'Kunne ikke utføre søk' });
  }
});

// Hent produkt via EAN
app.get('/api/products/barcode/:ean', async (req, res) => {
  try {
    const { ean } = req.params;
    
    if (!/^(\d{8}|\d{13})$/.test(ean)) {
      return res.status(400).json({ error: 'Ugyldig EAN-format' });
    }
    
    const cacheKey = `ean:${ean}`;
    const cached = getCached(cacheKey);
    if (cached) {
      return res.json({ ...cached, cached: true });
    }
    
    const products = await rateLimiter.execute(async () => {
      return kassalapp.getProductByEan(ean);
    });
    
    // Finn billigste og organiser data
    const result = {
      ean,
      found: products.length > 0,
      products: products,
      cheapest: products.reduce((min, p) => 
        (p.current_price && (!min.current_price || p.current_price < min.current_price)) ? p : min
      , products[0]),
      stores: products.map(p => ({
        name: p.store.name,
        price: p.current_price,
        url: p.url,
      })),
    };
    
    setCache(cacheKey, result);
    res.json(result);
  } catch (error) {
    console.error('EAN-feil:', error);
    res.status(500).json({ error: 'Kunne ikke hente produkt' });
  }
});

// Sammenlign priser
app.post('/api/products/compare', async (req, res) => {
  try {
    const { eans } = req.body;
    
    if (!Array.isArray(eans) || eans.length === 0) {
      return res.status(400).json({ error: 'EAN-liste er påkrevd' });
    }
    
    const results = await Promise.all(
      eans.map(async (ean) => {
        try {
          const products = await rateLimiter.execute(async () => {
            return kassalapp.getProductByEan(ean);
          });
          
          return {
            ean,
            name: products[0]?.name,
            prices: products.map(p => ({
              store: p.store.name,
              price: p.current_price,
              unitPrice: p.current_unit_price,
            })).sort((a, b) => (a.price || 999) - (b.price || 999)),
          };
        } catch (error) {
          return { ean, error: 'Ikke funnet' };
        }
      })
    );
    
    res.json({ results });
  } catch (error) {
    console.error('Sammenligningsfeil:', error);
    res.status(500).json({ error: 'Kunne ikke sammenligne priser' });
  }
});

// Finn nærmeste butikker
app.get('/api/stores/nearby', async (req, res) => {
  try {
    const { lat, lng, radius = 5 } = req.query;
    
    if (!lat || !lng) {
      return res.status(400).json({ error: 'Latitude og longitude er påkrevd' });
    }
    
    const stores = await rateLimiter.execute(async () => {
      return kassalapp.getPhysicalStores({
        lat: parseFloat(lat),
        lng: parseFloat(lng),
        km: parseFloat(radius),
        size: 50,
      });
    });
    
    // Legg til åpningstider-status
    const now = new Date();
    const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
    const today = dayNames[now.getDay()];
    
    const enrichedStores = stores.data.map(store => {
      const todayHours = store.opening_hours[today];
      const isOpen = todayHours && !todayHours.closed;
      
      return {
        ...store,
        isOpen,
        todayHours,
      };
    });
    
    res.json({
      stores: enrichedStores,
      total: stores.meta.total,
    });
  } catch (error) {
    console.error('Butikksøkfeil:', error);
    res.status(500).json({ error: 'Kunne ikke hente butikker' });
  }
});

// Handleliste-optimalisering
app.post('/api/shopping-list/optimize', async (req, res) => {
  try {
    const { items, location, preferredStore } = req.body;
    
    if (!Array.isArray(items) || items.length === 0) {
      return res.status(400).json({ error: 'Handleliste er påkrevd' });
    }
    
    const optimized = [];
    let totalPrice = 0;
    
    for (const item of items) {
      const ean = item.ean || item;
      
      const products = await rateLimiter.execute(async () => {
        return kassalapp.getProductByEan(ean);
      });
      
      if (products.length > 0) {
        let selected;
        
        if (preferredStore) {
          selected = products.find(p => 
            p.store.name.toLowerCase().includes(preferredStore.toLowerCase())
          );
        }
        
        if (!selected) {
          selected = products.reduce((min, p) => 
            (p.current_price && (!min.current_price || p.current_price < min.current_price)) ? p : min
          );
        }
        
        optimized.push({
          ean,
          name: selected.name,
          store: selected.store.name,
          price: selected.current_price,
          url: selected.url,
        });
        
        if (selected.current_price) {
          totalPrice += selected.current_price;
        }
      }
    }
    
    res.json({
      items: optimized,
      totalPrice,
      savings: null, // Kan beregnes hvis vi sammenligner med dyreste alternativ
    });
  } catch (error) {
    console.error('Handleliste-feil:', error);
    res.status(500).json({ error: 'Kunne ikke optimalisere handleliste' });
  }
});

// Prisvarsler webhook endpoint
app.post('/api/alerts/webhook', async (req, res) => {
  try {
    const { ean, targetPrice, webhookUrl } = req.body;
    
    // Her ville du normalt sette opp en scheduled job eller lignende
    // For dette eksempelet returnerer vi bare en bekreftelse
    
    res.json({
      message: 'Prisvarsel opprettet',
      ean,
      targetPrice,
      webhookUrl,
      id: Date.now().toString(),
    });
  } catch (error) {
    res.status(500).json({ error: 'Kunne ikke opprette prisvarsel' });
  }
});

// WebSocket for sanntidsoppdateringer (med Socket.io)
import { createServer } from 'http';
import { Server } from 'socket.io';

const server = createServer(app);
const io = new Server(server, {
  cors: {
    origin: '*',
    methods: ['GET', 'POST'],
  },
});

// Track intervals per socket to prevent memory leaks
const socketIntervals = new Map();

io.on('connection', (socket) => {
  console.log('Klient tilkoblet:', socket.id);
  
  socket.on('track-price', async (ean) => {
    // Clear any existing interval for this socket
    if (socketIntervals.has(socket.id)) {
      clearInterval(socketIntervals.get(socket.id));
    }
    
    // Simuler prissjekking hvert 30. sekund
    const interval = setInterval(async () => {
      try {
        const products = await kassalapp.getProductByEan(ean);
        const cheapest = products.reduce((min, p) => 
          (p.current_price && (!min.current_price || p.current_price < min.current_price)) ? p : min
        );
        
        socket.emit('price-update', {
          ean,
          product: cheapest.name,
          price: cheapest.current_price,
          store: cheapest.store.name,
          timestamp: new Date().toISOString(),
        });
      } catch (error) {
        socket.emit('error', { message: 'Kunne ikke hente pris' });
      }
    }, 30000);
    
    // Store interval reference
    socketIntervals.set(socket.id, interval);
  });
  
  socket.on('disconnect', () => {
    // Clean up interval on disconnect
    if (socketIntervals.has(socket.id)) {
      clearInterval(socketIntervals.get(socket.id));
      socketIntervals.delete(socket.id);
    }
    console.log('Klient frakoblet:', socket.id);
  });
});

// Start server
server.listen(port, () => {
  console.log(`🚀 Kassalapp API Wrapper kjører på http://localhost:${port}`);
  console.log(`📚 API dokumentasjon: http://localhost:${port}/api-docs`);
});

// Graceful shutdown
process.on('SIGTERM', () => {
  console.log('SIGTERM mottatt. Avslutter...');
  server.close(() => {
    console.log('Server avsluttet');
    process.exit(0);
  });
});

Praktiske eksempler

1. Prissporing-tjeneste

javascript
// price-tracker.js
import { KassalappClient } from './kassalapp-client.js';
import fs from 'fs/promises';
import nodemailer from 'nodemailer';

class PriceTracker {
  constructor(apiKey) {
    this.client = new KassalappClient(apiKey);
    this.trackedProducts = new Map();
    this.priceHistory = new Map();
  }
  
  // Legg til produkt for sporing
  addProduct(ean, targetPrice, email) {
    this.trackedProducts.set(ean, {
      targetPrice,
      email,
      lastChecked: null,
      lowestPrice: null,
      alerts: [],
    });
  }
  
  // Sjekk priser for alle sporede produkter
  async checkPrices() {
    console.log(`🔍 Sjekker priser for ${this.trackedProducts.size} produkter...`);
    
    for (const [ean, config] of this.trackedProducts.entries()) {
      try {
        const products = await this.client.getProductByEan(ean);
        
        if (products.length === 0) continue;
        
        const cheapest = products.reduce((min, p) => 
          (p.current_price && (!min.current_price || p.current_price < min.current_price)) ? p : min
        );
        
        const currentPrice = cheapest.current_price;
        
        // Oppdater historikk
        if (!this.priceHistory.has(ean)) {
          this.priceHistory.set(ean, []);
        }
        
        this.priceHistory.get(ean).push({
          price: currentPrice,
          store: cheapest.store.name,
          timestamp: new Date().toISOString(),
        });
        
        // Sjekk om vi skal sende varsel
        if (currentPrice && currentPrice <= config.targetPrice) {
          if (!config.lowestPrice || currentPrice < config.lowestPrice) {
            await this.sendAlert(ean, cheapest, config);
            config.lowestPrice = currentPrice;
          }
        }
        
        config.lastChecked = new Date().toISOString();
        
        // Vent litt mellom hver sjekk
        await this.delay(1000);
      } catch (error) {
        console.error(`Feil ved sjekk av ${ean}:`, error.message);
      }
    }
    
    await this.saveHistory();
  }
  
  // Send e-postvarsel
  async sendAlert(ean, product, config) {
    console.log(`📧 Sender prisvarsel for ${product.name}`);
    
    const transporter = nodemailer.createTransport({
      service: 'gmail',
      auth: {
        user: process.env.EMAIL_USER,
        pass: process.env.EMAIL_PASS,
      },
    });
    
    const mailOptions = {
      from: process.env.EMAIL_USER,
      to: config.email,
      subject: `Prisvarsel: ${product.name}`,
      html: `
        <h2>Godt nytt! Prisen har falt!</h2>
        <p><strong>${product.name}</strong></p>
        <p>Ny pris: <strong>${product.current_price} kr</strong></p>
        <p>Butikk: ${product.store.name}</p>
        <p>Din målpris: ${config.targetPrice} kr</p>
        <p><a href="${product.url}">Se produkt</a></p>
      `,
    };
    
    try {
      await transporter.sendMail(mailOptions);
      config.alerts.push({
        price: product.current_price,
        timestamp: new Date().toISOString(),
      });
    } catch (error) {
      console.error('Kunne ikke sende e-post:', error);
    }
  }
  
  // Lagre prishistorikk til fil
  async saveHistory() {
    const data = {
      tracked: Array.from(this.trackedProducts.entries()),
      history: Array.from(this.priceHistory.entries()),
    };
    
    await fs.writeFile(
      'price-history.json',
      JSON.stringify(data, null, 2)
    );
  }
  
  // Last inn tidligere data
  async loadHistory() {
    try {
      const data = await fs.readFile('price-history.json', 'utf-8');
      const parsed = JSON.parse(data);
      
      this.trackedProducts = new Map(parsed.tracked);
      this.priceHistory = new Map(parsed.history);
    } catch (error) {
      console.log('Ingen tidligere historikk funnet');
    }
  }
  
  // Generer rapport
  generateReport() {
    const report = [];
    
    for (const [ean, history] of this.priceHistory.entries()) {
      const config = this.trackedProducts.get(ean);
      const prices = history.map(h => h.price).filter(p => p);
      
      if (prices.length === 0) continue;
      
      const min = Math.min(...prices);
      const max = Math.max(...prices);
      const avg = prices.reduce((a, b) => a + b, 0) / prices.length;
      const latest = history[history.length - 1];
      
      report.push({
        ean,
        lastPrice: latest.price,
        lastStore: latest.store,
        lowestPrice: min,
        highestPrice: max,
        averagePrice: avg.toFixed(2),
        priceDrops: config?.alerts.length || 0,
        dataPoints: history.length,
      });
    }
    
    return report;
  }
  
  delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
  
  // Start automatisk sjekking
  startTracking(intervalMinutes = 60) {
    console.log(`🚀 Starter prissporing (sjekker hver ${intervalMinutes}. minutt)`);
    
    // Første sjekk umiddelbart
    this.checkPrices();
    
    // Deretter med intervall
    setInterval(() => {
      this.checkPrices();
    }, intervalMinutes * 60 * 1000);
  }
}

// Eksempel bruk
async function startPriceTracking() {
  const tracker = new PriceTracker(process.env.KASSALAPP_API_KEY);
  
  // Last inn tidligere data
  await tracker.loadHistory();
  
  // Legg til produkter for sporing
  tracker.addProduct('7039010576963', 50, 'din@epost.no'); // Grandiosa
  tracker.addProduct('7032069715253', 15, 'din@epost.no'); // Melk
  
  // Start automatisk sporing
  tracker.startTracking(30); // Sjekk hver 30. minutt
  
  // Generer rapport hver dag
  setInterval(() => {
    const report = tracker.generateReport();
    console.log('📊 Daglig rapport:', report);
  }, 24 * 60 * 60 * 1000);
}

startPriceTracking();

2. Strekkode-skanning backend

javascript
// barcode-api.js
import express from 'express';
import multer from 'multer';
import Quagga from 'quagga';
import { KassalappClient } from './kassalapp-client.js';

const app = express();
const upload = multer({ memory: true });
const kassalapp = new KassalappClient(process.env.KASSALAPP_API_KEY);

// Dekod strekkode fra bilde
async function decodeBarcode(buffer) {
  return new Promise((resolve, reject) => {
    Quagga.decodeSingle({
      src: buffer,
      numOfWorkers: 0,
      inputStream: {
        size: 800,
      },
      decoder: {
        readers: ['ean_reader', 'ean_8_reader'],
      },
    }, (result) => {
      if (result && result.codeResult) {
        resolve(result.codeResult.code);
      } else {
        reject(new Error('Kunne ikke lese strekkode'));
      }
    });
  });
}

// Endpoint for bildeopplasting og skanning
app.post('/api/scan', upload.single('image'), async (req, res) => {
  try {
    if (!req.file) {
      return res.status(400).json({ error: 'Bilde mangler' });
    }
    
    // Dekod strekkode fra bilde
    const ean = await decodeBarcode(req.file.buffer);
    
    // Hent produktinformasjon
    const products = await kassalapp.getProductByEan(ean);
    
    if (products.length === 0) {
      return res.json({
        ean,
        found: false,
        message: 'Produktet ble ikke funnet i databasen',
      });
    }
    
    // Finn billigste og dyreste
    const sorted = [...products].sort((a, b) => 
      (a.current_price || 999) - (b.current_price || 999)
    );
    
    const cheapest = sorted[0];
    const mostExpensive = sorted[sorted.length - 1];
    const savings = mostExpensive.current_price - cheapest.current_price;
    
    res.json({
      ean,
      found: true,
      product: {
        name: products[0].name,
        brand: products[0].brand,
        image: products[0].image,
        ingredients: products[0].ingredients,
        allergens: products[0].allergens,
        nutrition: products[0].nutrition,
      },
      pricing: {
        cheapest: {
          store: cheapest.store.name,
          price: cheapest.current_price,
          unitPrice: cheapest.current_unit_price,
          url: cheapest.url,
        },
        mostExpensive: {
          store: mostExpensive.store.name,
          price: mostExpensive.current_price,
        },
        potentialSavings: savings,
      },
      availability: products.map(p => ({
        store: p.store.name,
        price: p.current_price,
        inStock: p.current_price !== null,
      })),
    });
  } catch (error) {
    console.error('Skanningsfeil:', error);
    res.status(500).json({ error: 'Kunne ikke behandle bilde' });
  }
});

// Manuell EAN-inntasting
app.get('/api/scan/:ean', async (req, res) => {
  try {
    const { ean } = req.params;
    
    const products = await kassalapp.getProductByEan(ean);
    
    if (products.length === 0) {
      return res.status(404).json({
        error: 'Produkt ikke funnet',
        ean,
      });
    }
    
    // Samme logikk som bildeskanningen
    res.json({
      ean,
      product: products[0],
      stores: products.map(p => ({
        name: p.store.name,
        price: p.current_price,
        url: p.url,
      })),
    });
  } catch (error) {
    res.status(500).json({ error: 'Serverfeil' });
  }
});

app.listen(3000, () => {
  console.log('🔍 Strekkode-API kjører på http://localhost:3000');
});

3. Webhook-integrasjon for prisvarsler

javascript
// webhook-service.js
import express from 'express';
import axios from 'axios';
import crypto from 'crypto';
import { KassalappClient } from './kassalapp-client.js';

class WebhookService {
  constructor(apiKey) {
    this.client = new KassalappClient(apiKey);
    this.webhooks = new Map();
  }
  
  // Registrer webhook
  registerWebhook(id, config) {
    this.webhooks.set(id, {
      url: config.url,
      secret: config.secret || crypto.randomBytes(32).toString('hex'),
      events: config.events || ['price_drop', 'back_in_stock'],
      filters: config.filters || {},
      active: true,
      created: new Date().toISOString(),
      lastTriggered: null,
      errorCount: 0,
    });
    
    return this.webhooks.get(id);
  }
  
  // Send webhook
  async sendWebhook(webhookId, event, data) {
    const webhook = this.webhooks.get(webhookId);
    if (!webhook || !webhook.active) return;
    
    const payload = {
      event,
      data,
      timestamp: new Date().toISOString(),
      webhookId,
    };
    
    // Generer signatur for verifisering
    const signature = crypto
      .createHmac('sha256', webhook.secret)
      .update(JSON.stringify(payload))
      .digest('hex');
    
    try {
      const response = await axios.post(webhook.url, payload, {
        headers: {
          'Content-Type': 'application/json',
          'X-Webhook-Signature': signature,
          'X-Webhook-Event': event,
        },
        timeout: 10000,
      });
      
      webhook.lastTriggered = new Date().toISOString();
      webhook.errorCount = 0;
      
      console.log(`✅ Webhook sendt: ${event} til ${webhook.url}`);
      return { success: true, status: response.status };
    } catch (error) {
      webhook.errorCount++;
      
      // Deaktiver webhook etter 5 feil
      if (webhook.errorCount >= 5) {
        webhook.active = false;
        console.log(`❌ Webhook deaktivert etter 5 feil: ${webhookId}`);
      }
      
      console.error(`Webhook-feil: ${error.message}`);
      return { success: false, error: error.message };
    }
  }
  
  // Overvåk produkter for hendelser
  async monitorProducts() {
    for (const [webhookId, webhook] of this.webhooks.entries()) {
      if (!webhook.active) continue;
      
      const { ean, targetPrice } = webhook.filters;
      
      if (ean) {
        try {
          const products = await this.client.getProductByEan(ean);
          
          if (products.length > 0) {
            const cheapest = products.reduce((min, p) => 
              (p.current_price && (!min.current_price || p.current_price < min.current_price)) ? p : min
            );
            
            // Sjekk for prisfall
            if (webhook.events.includes('price_drop') && targetPrice) {
              if (cheapest.current_price && cheapest.current_price <= targetPrice) {
                await this.sendWebhook(webhookId, 'price_drop', {
                  product: cheapest,
                  targetPrice,
                  currentPrice: cheapest.current_price,
                });
              }
            }
            
            // Sjekk for tilbake på lager
            if (webhook.events.includes('back_in_stock')) {
              const inStock = products.filter(p => p.current_price !== null);
              if (inStock.length > 0 && !webhook.lastStockStatus) {
                await this.sendWebhook(webhookId, 'back_in_stock', {
                  product: inStock[0],
                  stores: inStock.map(p => p.store.name),
                });
              }
              webhook.lastStockStatus = inStock.length > 0;
            }
          }
        } catch (error) {
          console.error(`Overvåkingsfeil for ${webhookId}:`, error);
        }
      }
      
      // Vent mellom hver webhook-sjekk
      await this.delay(500);
    }
  }
  
  delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
  
  // Start overvåking
  startMonitoring(intervalMinutes = 15) {
    console.log(`🔔 Starter webhook-overvåking (hver ${intervalMinutes}. minutt)`);
    
    setInterval(() => {
      this.monitorProducts();
    }, intervalMinutes * 60 * 1000);
    
    // Første sjekk umiddelbart
    this.monitorProducts();
  }
}

// Express server for webhook-administrasjon
const app = express();
app.use(express.json());

const webhookService = new WebhookService(process.env.KASSALAPP_API_KEY);

// Registrer ny webhook
app.post('/webhooks', (req, res) => {
  const webhookId = crypto.randomBytes(16).toString('hex');
  const webhook = webhookService.registerWebhook(webhookId, req.body);
  
  res.json({
    id: webhookId,
    secret: webhook.secret,
    message: 'Webhook registrert',
  });
});

// List webhooks
app.get('/webhooks', (req, res) => {
  const webhooks = Array.from(webhookService.webhooks.entries()).map(([id, config]) => ({
    id,
    url: config.url,
    events: config.events,
    active: config.active,
    errorCount: config.errorCount,
    lastTriggered: config.lastTriggered,
  }));
  
  res.json({ webhooks });
});

// Slett webhook
app.delete('/webhooks/:id', (req, res) => {
  const { id } = req.params;
  
  if (webhookService.webhooks.delete(id)) {
    res.json({ message: 'Webhook slettet' });
  } else {
    res.status(404).json({ error: 'Webhook ikke funnet' });
  }
});

// Test webhook
app.post('/webhooks/:id/test', async (req, res) => {
  const { id } = req.params;
  
  const result = await webhookService.sendWebhook(id, 'test', {
    message: 'Dette er en test',
    timestamp: new Date().toISOString(),
  });
  
  res.json(result);
});

// Start server
app.listen(3001, () => {
  console.log('🔔 Webhook-tjeneste kjører på http://localhost:3001');
  webhookService.startMonitoring(15);
});

// Eksempel webhook-mottaker
const receiverApp = express();
receiverApp.use(express.json());

receiverApp.post('/webhook-receiver', (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const event = req.headers['x-webhook-event'];
  
  console.log(`📨 Mottatt webhook: ${event}`);
  console.log('Data:', req.body);
  
  // Verifiser signatur her hvis ønskelig
  
  res.status(200).json({ received: true });
});

receiverApp.listen(3002, () => {
  console.log('📬 Webhook-mottaker kjører på http://localhost:3002/webhook-receiver');
});

Avslutning

Denne guiden har vist deg hvordan du kan bruke Kassalapp API med Node.js for å bygge kraftige applikasjoner for prissammenligning og handleplanlegging. Med eksemplene og kodesnuttene kan du nå:

  • ✅ Søke etter produkter og sammenligne priser på tvers av butikker
  • 📱 Skanne strekkoder og få detaljert produktinformasjon
  • 🗺️ Finne nærmeste butikker basert på lokasjon
  • 🔔 Bygge prisvarsler og webhook-integrasjoner for automatisk overvåking
  • 🚀 Lage en komplett Express.js API wrapper med modern sikkerhet
  • 📊 Implementere avansert feilhåndtering og logging
  • ⚡ Håndtere rate limiting og optimalisere ytelse

Beste praksis sammenfattet:

  1. Sikkerhet først: Bruk alltid HTTPS, valider input, og håndter API-nøkler sikkert
  2. Robust feilhåndtering: Implementer retry-logikk og detaljert logging
  3. Respekter rate limits: Implementer kø-basert behandling for bulk-operasjoner
  4. TypeScript: Bruk typer for bedre kodekvalitet og færre feil
  5. Miljøkonfigurasjon: Hold all konfigurasjon i miljøvariabler
  6. Testing: Test alle API-integrasjoner grundig

For mer informasjon og oppdatert dokumentasjon, besøk https://kassal.app/api.

Nyttige ressurser

Lisens og bruksvilkår

Husk å følge Kassalapp sine bruksvilkår og respekter rate limits. For kommersiell bruk eller høyere rate limits, kontakt Kassalapp for enterprise-løsninger.

Happy coding! 🚀