Просмотр исходного кода

feat: GDPR-compliant webshop data access refactoring #48

- Database migrations: API keys, data access permissions, drop customer/order cache
- New Edge Functions: webshop-data-api (unified REST API) and api-key-management
- Refactored woocommerce-scheduled-sync to products-only sync
- Added platform adapters for unified data format across all platforms
- API key authentication with bcrypt hashing and rate limiting (100 req/min)
- Real-time data fetching from webshops (no persistent personal data storage)
- Deployed Edge Functions: webshop-data-api (540.5kB), api-key-management (52.39kB)

Related: #48 GDPR compliance - Issue #48
Claude 5 месяцев назад
Родитель
Сommit
d508dbeb53

+ 322 - 0
supabase/functions/_shared/api-key-auth.ts

@@ -0,0 +1,322 @@
+/**
+ * API Key Authentication Middleware
+ *
+ * Provides authentication for REST API endpoints using custom API keys.
+ * Validates API keys, checks permissions, and tracks usage.
+ *
+ * Related: Issue #48 - GDPR-compliant webshop sync refactoring
+ */
+
+import { createClient, SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.39.7";
+import * as bcrypt from "https://deno.land/x/bcrypt@v0.4.1/mod.ts";
+
+// Rate limiting configuration
+const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute
+const RATE_LIMIT_MAX_REQUESTS = 100; // 100 requests per minute
+
+// In-memory rate limiting store (consider Redis for production)
+const rateLimitStore = new Map<string, { count: number; resetAt: number }>();
+
+export interface ApiKeyValidationResult {
+  valid: boolean;
+  userId?: string;
+  permissions?: Record<string, unknown>;
+  errorMessage?: string;
+  errorCode?: string;
+}
+
+export interface RateLimitResult {
+  allowed: boolean;
+  remaining: number;
+  resetAt: number;
+}
+
+/**
+ * Extract API key from Authorization header
+ */
+export function extractApiKey(request: Request): string | null {
+  const authHeader = request.headers.get("Authorization");
+
+  if (!authHeader) {
+    return null;
+  }
+
+  // Support both "Bearer api_shopcall_xxx" and "api_shopcall_xxx" formats
+  const match = authHeader.match(/^(?:Bearer\s+)?(api_shopcall_[a-zA-Z0-9_-]+)$/i);
+  return match ? match[1] : null;
+}
+
+/**
+ * Validate API key format
+ */
+export function isValidApiKeyFormat(apiKey: string): boolean {
+  return /^api_shopcall_[a-zA-Z0-9_-]{32,}$/.test(apiKey);
+}
+
+/**
+ * Check rate limit for an API key
+ */
+export function checkRateLimit(apiKey: string): RateLimitResult {
+  const now = Date.now();
+  const record = rateLimitStore.get(apiKey);
+
+  // Clean up expired entries
+  if (record && now > record.resetAt) {
+    rateLimitStore.delete(apiKey);
+  }
+
+  const currentRecord = rateLimitStore.get(apiKey);
+
+  if (!currentRecord) {
+    // First request in this window
+    const resetAt = now + RATE_LIMIT_WINDOW_MS;
+    rateLimitStore.set(apiKey, { count: 1, resetAt });
+    return {
+      allowed: true,
+      remaining: RATE_LIMIT_MAX_REQUESTS - 1,
+      resetAt,
+    };
+  }
+
+  if (currentRecord.count >= RATE_LIMIT_MAX_REQUESTS) {
+    // Rate limit exceeded
+    return {
+      allowed: false,
+      remaining: 0,
+      resetAt: currentRecord.resetAt,
+    };
+  }
+
+  // Increment counter
+  currentRecord.count++;
+  rateLimitStore.set(apiKey, currentRecord);
+
+  return {
+    allowed: true,
+    remaining: RATE_LIMIT_MAX_REQUESTS - currentRecord.count,
+    resetAt: currentRecord.resetAt,
+  };
+}
+
+/**
+ * Validate API key against database
+ */
+export async function validateApiKey(
+  apiKey: string,
+  supabaseAdmin: SupabaseClient,
+  requiredPermission?: string
+): Promise<ApiKeyValidationResult> {
+  try {
+    // Check API key format
+    if (!isValidApiKeyFormat(apiKey)) {
+      return {
+        valid: false,
+        errorMessage: "Invalid API key format",
+        errorCode: "INVALID_FORMAT",
+      };
+    }
+
+    // Query database for API key
+    const { data: keyData, error: keyError } = await supabaseAdmin
+      .from("user_api_keys")
+      .select("*")
+      .eq("api_key", apiKey)
+      .single();
+
+    if (keyError || !keyData) {
+      return {
+        valid: false,
+        errorMessage: "API key not found",
+        errorCode: "NOT_FOUND",
+      };
+    }
+
+    // Check if key is active
+    if (!keyData.is_active) {
+      return {
+        valid: false,
+        errorMessage: "API key is inactive",
+        errorCode: "INACTIVE",
+      };
+    }
+
+    // Check expiration
+    if (keyData.expires_at) {
+      const expiresAt = new Date(keyData.expires_at);
+      if (expiresAt < new Date()) {
+        return {
+          valid: false,
+          errorMessage: "API key has expired",
+          errorCode: "EXPIRED",
+        };
+      }
+    }
+
+    // Check specific permission if required
+    if (requiredPermission && keyData.permissions) {
+      const permissions = keyData.permissions as Record<string, unknown>;
+      if (!permissions[requiredPermission]) {
+        return {
+          valid: false,
+          errorMessage: `Missing required permission: ${requiredPermission}`,
+          errorCode: "INSUFFICIENT_PERMISSIONS",
+        };
+      }
+    }
+
+    // Update last_used_at timestamp (non-blocking)
+    supabaseAdmin
+      .from("user_api_keys")
+      .update({ last_used_at: new Date().toISOString() })
+      .eq("api_key", apiKey)
+      .then(() => {})
+      .catch((err) => console.error("Failed to update last_used_at:", err));
+
+    return {
+      valid: true,
+      userId: keyData.user_id,
+      permissions: keyData.permissions as Record<string, unknown>,
+    };
+  } catch (error) {
+    console.error("Error validating API key:", error);
+    return {
+      valid: false,
+      errorMessage: "Internal error during API key validation",
+      errorCode: "INTERNAL_ERROR",
+    };
+  }
+}
+
+/**
+ * Generate a new API key
+ */
+export function generateApiKey(): string {
+  const characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-";
+  const length = 48; // 48 characters for strong security
+  let result = "api_shopcall_";
+
+  for (let i = 0; i < length; i++) {
+    result += characters.charAt(Math.floor(Math.random() * characters.length));
+  }
+
+  return result;
+}
+
+/**
+ * Hash API key using bcrypt
+ */
+export async function hashApiKey(apiKey: string): Promise<string> {
+  const salt = await bcrypt.genSalt(12);
+  return await bcrypt.hash(apiKey, salt);
+}
+
+/**
+ * Verify API key against hash
+ */
+export async function verifyApiKey(apiKey: string, hash: string): Promise<boolean> {
+  try {
+    return await bcrypt.compare(apiKey, hash);
+  } catch (error) {
+    console.error("Error verifying API key:", error);
+    return false;
+  }
+}
+
+/**
+ * Middleware function to protect Edge Function endpoints
+ *
+ * Usage:
+ * ```typescript
+ * const result = await requireApiKey(req, supabaseAdmin, "webshop_data");
+ * if (!result.valid) {
+ *   return new Response(JSON.stringify({ error: result.errorMessage }), { status: 401 });
+ * }
+ * const userId = result.userId!;
+ * ```
+ */
+export async function requireApiKey(
+  request: Request,
+  supabaseAdmin: SupabaseClient,
+  requiredPermission?: string
+): Promise<ApiKeyValidationResult> {
+  // Extract API key from header
+  const apiKey = extractApiKey(request);
+
+  if (!apiKey) {
+    return {
+      valid: false,
+      errorMessage: "Missing API key. Use Authorization: Bearer api_shopcall_xxx",
+      errorCode: "MISSING_API_KEY",
+    };
+  }
+
+  // Check rate limit
+  const rateLimitResult = checkRateLimit(apiKey);
+  if (!rateLimitResult.allowed) {
+    return {
+      valid: false,
+      errorMessage: `Rate limit exceeded. Try again after ${new Date(rateLimitResult.resetAt).toISOString()}`,
+      errorCode: "RATE_LIMIT_EXCEEDED",
+    };
+  }
+
+  // Validate API key
+  const validationResult = await validateApiKey(apiKey, supabaseAdmin, requiredPermission);
+
+  return validationResult;
+}
+
+/**
+ * Create error response for API key validation failures
+ */
+export function createApiKeyErrorResponse(result: ApiKeyValidationResult): Response {
+  const statusCode = result.errorCode === "RATE_LIMIT_EXCEEDED" ? 429 : 401;
+
+  return new Response(
+    JSON.stringify({
+      error: result.errorMessage,
+      code: result.errorCode,
+      timestamp: new Date().toISOString(),
+    }),
+    {
+      status: statusCode,
+      headers: {
+        "Content-Type": "application/json",
+        "WWW-Authenticate": 'Bearer realm="API", error="invalid_token"',
+      },
+    }
+  );
+}
+
+/**
+ * Add rate limit headers to response
+ */
+export function addRateLimitHeaders(response: Response, apiKey: string): Response {
+  const rateLimitResult = checkRateLimit(apiKey);
+
+  const headers = new Headers(response.headers);
+  headers.set("X-RateLimit-Limit", RATE_LIMIT_MAX_REQUESTS.toString());
+  headers.set("X-RateLimit-Remaining", rateLimitResult.remaining.toString());
+  headers.set("X-RateLimit-Reset", new Date(rateLimitResult.resetAt).toISOString());
+
+  return new Response(response.body, {
+    status: response.status,
+    statusText: response.statusText,
+    headers,
+  });
+}
+
+/**
+ * Cleanup expired rate limit entries (call periodically)
+ */
+export function cleanupRateLimitStore(): void {
+  const now = Date.now();
+  for (const [key, value] of rateLimitStore.entries()) {
+    if (now > value.resetAt) {
+      rateLimitStore.delete(key);
+    }
+  }
+}
+
+// Run cleanup every 5 minutes
+setInterval(cleanupRateLimitStore, 5 * 60 * 1000);

+ 489 - 0
supabase/functions/_shared/platform-adapters.ts

@@ -0,0 +1,489 @@
+/**
+ * Platform Adapters for Unified Data Format
+ *
+ * Converts platform-specific data structures (Shopify, WooCommerce, ShopRenter)
+ * into unified formats for consistent API responses.
+ *
+ * Related: Issue #48 - GDPR-compliant webshop sync refactoring
+ */
+
+// ============================================================================
+// Unified Data Interfaces
+// ============================================================================
+
+export interface UnifiedAddress {
+  first_name?: string | null;
+  last_name?: string | null;
+  company?: string | null;
+  address1?: string | null;
+  address2?: string | null;
+  city?: string | null;
+  province?: string | null;
+  province_code?: string | null;
+  country?: string | null;
+  country_code?: string | null;
+  zip?: string | null;
+  phone?: string | null;
+}
+
+export interface UnifiedCustomer {
+  id: string;
+  platform: "shopify" | "woocommerce" | "shoprenter";
+  email: string | null;
+  first_name: string | null;
+  last_name: string | null;
+  phone: string | null;
+  orders_count: number;
+  total_spent: number;
+  currency: string;
+  created_at: string | null;
+  updated_at: string | null;
+  addresses?: UnifiedAddress[];
+  default_address?: UnifiedAddress | null;
+  tags?: string[];
+  note?: string | null;
+  accepts_marketing?: boolean;
+  state?: string; // Customer state (enabled, disabled, etc.)
+  platform_specific: unknown; // Original platform data
+}
+
+export interface UnifiedLineItem {
+  id: string;
+  product_id?: string | null;
+  variant_id?: string | null;
+  name: string;
+  sku?: string | null;
+  quantity: number;
+  price: number;
+  total: number;
+  tax?: number | null;
+  image_url?: string | null;
+}
+
+export interface UnifiedOrder {
+  id: string;
+  platform: "shopify" | "woocommerce" | "shoprenter";
+  order_number: string;
+  name?: string | null; // Display name (e.g., "#1001")
+  status: string;
+  financial_status?: string | null;
+  fulfillment_status?: string | null;
+  total_price: number;
+  subtotal_price?: number | null;
+  total_tax?: number | null;
+  currency: string;
+  customer_name: string | null;
+  customer_email: string | null;
+  customer_phone?: string | null;
+  line_items: UnifiedLineItem[];
+  billing_address?: UnifiedAddress | null;
+  shipping_address?: UnifiedAddress | null;
+  note?: string | null;
+  tags?: string[];
+  created_at: string;
+  updated_at: string | null;
+  platform_specific: unknown; // Original platform data
+}
+
+export interface UnifiedProduct {
+  id: string;
+  platform: "shopify" | "woocommerce" | "shoprenter";
+  title: string;
+  description?: string | null;
+  short_description?: string | null;
+  sku?: string | null;
+  price: number;
+  compare_at_price?: number | null;
+  currency: string;
+  status: string;
+  vendor?: string | null;
+  product_type?: string | null;
+  tags?: string[];
+  images?: Array<{
+    id?: string;
+    src: string;
+    alt?: string | null;
+  }>;
+  variants?: Array<{
+    id: string;
+    title?: string;
+    sku?: string | null;
+    price: number;
+    inventory_quantity?: number | null;
+  }>;
+  inventory_quantity?: number | null;
+  stock_status?: string | null;
+  created_at?: string | null;
+  updated_at?: string | null;
+  platform_specific: unknown; // Original platform data
+}
+
+// ============================================================================
+// Shopify Adapters
+// ============================================================================
+
+export function adaptShopifyCustomer(shopifyCustomer: any): UnifiedCustomer {
+  return {
+    id: String(shopifyCustomer.id),
+    platform: "shopify",
+    email: shopifyCustomer.email || null,
+    first_name: shopifyCustomer.first_name || null,
+    last_name: shopifyCustomer.last_name || null,
+    phone: shopifyCustomer.phone || null,
+    orders_count: shopifyCustomer.orders_count || 0,
+    total_spent: parseFloat(shopifyCustomer.total_spent || "0"),
+    currency: shopifyCustomer.currency || "USD",
+    created_at: shopifyCustomer.created_at || null,
+    updated_at: shopifyCustomer.updated_at || null,
+    addresses: shopifyCustomer.addresses?.map((addr: any) => adaptShopifyAddress(addr)) || [],
+    default_address: shopifyCustomer.default_address
+      ? adaptShopifyAddress(shopifyCustomer.default_address)
+      : null,
+    tags: shopifyCustomer.tags ? shopifyCustomer.tags.split(",").map((t: string) => t.trim()) : [],
+    note: shopifyCustomer.note || null,
+    accepts_marketing: shopifyCustomer.accepts_marketing || false,
+    state: shopifyCustomer.state || "enabled",
+    platform_specific: shopifyCustomer,
+  };
+}
+
+export function adaptShopifyAddress(shopifyAddress: any): UnifiedAddress {
+  return {
+    first_name: shopifyAddress.first_name || null,
+    last_name: shopifyAddress.last_name || null,
+    company: shopifyAddress.company || null,
+    address1: shopifyAddress.address1 || null,
+    address2: shopifyAddress.address2 || null,
+    city: shopifyAddress.city || null,
+    province: shopifyAddress.province || null,
+    province_code: shopifyAddress.province_code || null,
+    country: shopifyAddress.country || null,
+    country_code: shopifyAddress.country_code || null,
+    zip: shopifyAddress.zip || null,
+    phone: shopifyAddress.phone || null,
+  };
+}
+
+export function adaptShopifyOrder(shopifyOrder: any): UnifiedOrder {
+  return {
+    id: String(shopifyOrder.id),
+    platform: "shopify",
+    order_number: String(shopifyOrder.order_number || shopifyOrder.id),
+    name: shopifyOrder.name || null,
+    status: shopifyOrder.financial_status || "pending",
+    financial_status: shopifyOrder.financial_status || null,
+    fulfillment_status: shopifyOrder.fulfillment_status || null,
+    total_price: parseFloat(shopifyOrder.total_price || "0"),
+    subtotal_price: parseFloat(shopifyOrder.subtotal_price || "0"),
+    total_tax: parseFloat(shopifyOrder.total_tax || "0"),
+    currency: shopifyOrder.currency || "USD",
+    customer_name: shopifyOrder.customer
+      ? `${shopifyOrder.customer.first_name || ""} ${shopifyOrder.customer.last_name || ""}`.trim()
+      : null,
+    customer_email: shopifyOrder.customer?.email || shopifyOrder.email || null,
+    customer_phone: shopifyOrder.customer?.phone || shopifyOrder.phone || null,
+    line_items: shopifyOrder.line_items?.map((item: any) => adaptShopifyLineItem(item)) || [],
+    billing_address: shopifyOrder.billing_address
+      ? adaptShopifyAddress(shopifyOrder.billing_address)
+      : null,
+    shipping_address: shopifyOrder.shipping_address
+      ? adaptShopifyAddress(shopifyOrder.shipping_address)
+      : null,
+    note: shopifyOrder.note || null,
+    tags: shopifyOrder.tags ? shopifyOrder.tags.split(",").map((t: string) => t.trim()) : [],
+    created_at: shopifyOrder.created_at || new Date().toISOString(),
+    updated_at: shopifyOrder.updated_at || null,
+    platform_specific: shopifyOrder,
+  };
+}
+
+export function adaptShopifyLineItem(shopifyItem: any): UnifiedLineItem {
+  return {
+    id: String(shopifyItem.id),
+    product_id: shopifyItem.product_id ? String(shopifyItem.product_id) : null,
+    variant_id: shopifyItem.variant_id ? String(shopifyItem.variant_id) : null,
+    name: shopifyItem.name || shopifyItem.title || "Unknown Product",
+    sku: shopifyItem.sku || null,
+    quantity: shopifyItem.quantity || 1,
+    price: parseFloat(shopifyItem.price || "0"),
+    total: parseFloat(shopifyItem.price || "0") * (shopifyItem.quantity || 1),
+    tax: parseFloat(shopifyItem.total_tax || "0"),
+  };
+}
+
+export function adaptShopifyProduct(shopifyProduct: any): UnifiedProduct {
+  const firstVariant = shopifyProduct.variants?.[0];
+
+  return {
+    id: String(shopifyProduct.id),
+    platform: "shopify",
+    title: shopifyProduct.title || "Untitled Product",
+    description: shopifyProduct.body_html || null,
+    sku: firstVariant?.sku || null,
+    price: parseFloat(firstVariant?.price || "0"),
+    compare_at_price: firstVariant?.compare_at_price
+      ? parseFloat(firstVariant.compare_at_price)
+      : null,
+    currency: "USD", // Shopify doesn't include currency in product data
+    status: shopifyProduct.status || "active",
+    vendor: shopifyProduct.vendor || null,
+    product_type: shopifyProduct.product_type || null,
+    tags: shopifyProduct.tags ? shopifyProduct.tags.split(",").map((t: string) => t.trim()) : [],
+    images: shopifyProduct.images?.map((img: any) => ({
+      id: String(img.id),
+      src: img.src,
+      alt: img.alt || null,
+    })) || [],
+    variants: shopifyProduct.variants?.map((v: any) => ({
+      id: String(v.id),
+      title: v.title,
+      sku: v.sku || null,
+      price: parseFloat(v.price || "0"),
+      inventory_quantity: v.inventory_quantity || 0,
+    })) || [],
+    inventory_quantity: firstVariant?.inventory_quantity || 0,
+    created_at: shopifyProduct.created_at || null,
+    updated_at: shopifyProduct.updated_at || null,
+    platform_specific: shopifyProduct,
+  };
+}
+
+// ============================================================================
+// WooCommerce Adapters
+// ============================================================================
+
+export function adaptWooCommerceCustomer(wcCustomer: any): UnifiedCustomer {
+  return {
+    id: String(wcCustomer.id),
+    platform: "woocommerce",
+    email: wcCustomer.email || null,
+    first_name: wcCustomer.first_name || null,
+    last_name: wcCustomer.last_name || null,
+    phone: wcCustomer.billing?.phone || null,
+    orders_count: wcCustomer.orders_count || 0,
+    total_spent: parseFloat(wcCustomer.total_spent || "0"),
+    currency: "USD", // WooCommerce doesn't include currency in customer data
+    created_at: wcCustomer.date_created || null,
+    updated_at: wcCustomer.date_modified || null,
+    addresses: [
+      wcCustomer.billing ? adaptWooCommerceAddress(wcCustomer.billing) : null,
+      wcCustomer.shipping ? adaptWooCommerceAddress(wcCustomer.shipping) : null,
+    ].filter(Boolean) as UnifiedAddress[],
+    default_address: wcCustomer.billing ? adaptWooCommerceAddress(wcCustomer.billing) : null,
+    note: null,
+    accepts_marketing: false,
+    platform_specific: wcCustomer,
+  };
+}
+
+export function adaptWooCommerceAddress(wcAddress: any): UnifiedAddress {
+  return {
+    first_name: wcAddress.first_name || null,
+    last_name: wcAddress.last_name || null,
+    company: wcAddress.company || null,
+    address1: wcAddress.address_1 || null,
+    address2: wcAddress.address_2 || null,
+    city: wcAddress.city || null,
+    province: wcAddress.state || null,
+    country: wcAddress.country || null,
+    zip: wcAddress.postcode || null,
+    phone: wcAddress.phone || null,
+  };
+}
+
+export function adaptWooCommerceOrder(wcOrder: any): UnifiedOrder {
+  return {
+    id: String(wcOrder.id),
+    platform: "woocommerce",
+    order_number: String(wcOrder.number || wcOrder.id),
+    status: wcOrder.status || "pending",
+    total_price: parseFloat(wcOrder.total || "0"),
+    subtotal_price: parseFloat(wcOrder.line_items?.reduce((sum: number, item: any) =>
+      sum + parseFloat(item.subtotal || "0"), 0) || "0"),
+    total_tax: parseFloat(wcOrder.total_tax || "0"),
+    currency: wcOrder.currency || "USD",
+    customer_name: `${wcOrder.billing?.first_name || ""} ${wcOrder.billing?.last_name || ""}`.trim() || null,
+    customer_email: wcOrder.billing?.email || null,
+    customer_phone: wcOrder.billing?.phone || null,
+    line_items: wcOrder.line_items?.map((item: any) => adaptWooCommerceLineItem(item)) || [],
+    billing_address: wcOrder.billing ? adaptWooCommerceAddress(wcOrder.billing) : null,
+    shipping_address: wcOrder.shipping ? adaptWooCommerceAddress(wcOrder.shipping) : null,
+    note: wcOrder.customer_note || null,
+    created_at: wcOrder.date_created || new Date().toISOString(),
+    updated_at: wcOrder.date_modified || null,
+    platform_specific: wcOrder,
+  };
+}
+
+export function adaptWooCommerceLineItem(wcItem: any): UnifiedLineItem {
+  return {
+    id: String(wcItem.id),
+    product_id: wcItem.product_id ? String(wcItem.product_id) : null,
+    variant_id: wcItem.variation_id ? String(wcItem.variation_id) : null,
+    name: wcItem.name || "Unknown Product",
+    sku: wcItem.sku || null,
+    quantity: wcItem.quantity || 1,
+    price: parseFloat(wcItem.price || "0"),
+    total: parseFloat(wcItem.total || "0"),
+    tax: parseFloat(wcItem.total_tax || "0"),
+    image_url: wcItem.image?.src || null,
+  };
+}
+
+export function adaptWooCommerceProduct(wcProduct: any): UnifiedProduct {
+  return {
+    id: String(wcProduct.id),
+    platform: "woocommerce",
+    title: wcProduct.name || "Untitled Product",
+    description: wcProduct.description || null,
+    short_description: wcProduct.short_description || null,
+    sku: wcProduct.sku || null,
+    price: parseFloat(wcProduct.price || "0"),
+    compare_at_price: wcProduct.regular_price ? parseFloat(wcProduct.regular_price) : null,
+    currency: "USD", // WooCommerce doesn't include currency in product data
+    status: wcProduct.status || "publish",
+    product_type: wcProduct.type || null,
+    tags: wcProduct.tags?.map((tag: any) => tag.name) || [],
+    images: wcProduct.images?.map((img: any) => ({
+      id: String(img.id),
+      src: img.src,
+      alt: img.alt || null,
+    })) || [],
+    variants: wcProduct.variations ? [] : undefined, // WooCommerce requires separate API call for variations
+    inventory_quantity: wcProduct.stock_quantity || 0,
+    stock_status: wcProduct.stock_status || "instock",
+    created_at: wcProduct.date_created || null,
+    updated_at: wcProduct.date_modified || null,
+    platform_specific: wcProduct,
+  };
+}
+
+// ============================================================================
+// ShopRenter Adapters
+// ============================================================================
+
+export function adaptShopRenterCustomer(srCustomer: any): UnifiedCustomer {
+  return {
+    id: String(srCustomer.id),
+    platform: "shoprenter",
+    email: srCustomer.email || null,
+    first_name: srCustomer.firstname || null,
+    last_name: srCustomer.lastname || null,
+    phone: srCustomer.phone || null,
+    orders_count: 0, // ShopRenter doesn't provide this in customer data
+    total_spent: 0, // Would need to calculate from orders
+    currency: "HUF", // ShopRenter default currency
+    created_at: srCustomer.dateCreated || null,
+    updated_at: srCustomer.dateUpdated || null,
+    note: null,
+    accepts_marketing: srCustomer.newsletter === "1",
+    platform_specific: srCustomer,
+  };
+}
+
+export function adaptShopRenterOrder(srOrder: any): UnifiedOrder {
+  return {
+    id: String(srOrder.id),
+    platform: "shoprenter",
+    order_number: String(srOrder.innerId || srOrder.id),
+    status: srOrder.orderStatus?.name || "pending",
+    total_price: parseFloat(srOrder.totalGross || "0"),
+    subtotal_price: parseFloat(srOrder.totalNet || "0"),
+    total_tax: parseFloat(srOrder.totalGross || "0") - parseFloat(srOrder.totalNet || "0"),
+    currency: srOrder.currency || "HUF",
+    customer_name: `${srOrder.firstname || ""} ${srOrder.lastname || ""}`.trim() || null,
+    customer_email: srOrder.email || null,
+    customer_phone: srOrder.phone || null,
+    line_items: srOrder.orderProducts?.map((item: any) => adaptShopRenterLineItem(item)) || [],
+    billing_address: srOrder.invoiceAddress ? {
+      first_name: srOrder.invoiceAddress.firstname || null,
+      last_name: srOrder.invoiceAddress.lastname || null,
+      company: srOrder.invoiceAddress.company || null,
+      address1: srOrder.invoiceAddress.address || null,
+      city: srOrder.invoiceAddress.city || null,
+      zip: srOrder.invoiceAddress.zip || null,
+      phone: srOrder.invoiceAddress.phone || null,
+      country: srOrder.invoiceAddress.country?.name || null,
+    } : null,
+    shipping_address: srOrder.shippingAddress ? {
+      first_name: srOrder.shippingAddress.firstname || null,
+      last_name: srOrder.shippingAddress.lastname || null,
+      company: srOrder.shippingAddress.company || null,
+      address1: srOrder.shippingAddress.address || null,
+      city: srOrder.shippingAddress.city || null,
+      zip: srOrder.shippingAddress.zip || null,
+      phone: srOrder.shippingAddress.phone || null,
+      country: srOrder.shippingAddress.country?.name || null,
+    } : null,
+    note: srOrder.customerComment || null,
+    created_at: srOrder.dateCreated || new Date().toISOString(),
+    updated_at: srOrder.dateUpdated || null,
+    platform_specific: srOrder,
+  };
+}
+
+export function adaptShopRenterLineItem(srItem: any): UnifiedLineItem {
+  return {
+    id: String(srItem.id),
+    product_id: srItem.product?.id ? String(srItem.product.id) : null,
+    name: srItem.name || srItem.product?.name || "Unknown Product",
+    sku: srItem.product?.sku || null,
+    quantity: parseInt(srItem.quantity || "1"),
+    price: parseFloat(srItem.price || "0"),
+    total: parseFloat(srItem.price || "0") * parseInt(srItem.quantity || "1"),
+  };
+}
+
+export function adaptShopRenterProduct(srProduct: any): UnifiedProduct {
+  return {
+    id: String(srProduct.id),
+    platform: "shoprenter",
+    title: srProduct.name || "Untitled Product",
+    description: srProduct.description || null,
+    sku: srProduct.sku || null,
+    price: parseFloat(srProduct.price || "0"),
+    currency: "HUF",
+    status: srProduct.status === "1" ? "active" : "inactive",
+    tags: [],
+    images: srProduct.productImages?.map((img: any) => ({
+      id: String(img.id),
+      src: img.imageUrl,
+    })) || [],
+    inventory_quantity: parseInt(srProduct.stock || "0"),
+    created_at: srProduct.dateCreated || null,
+    updated_at: srProduct.dateUpdated || null,
+    platform_specific: srProduct,
+  };
+}
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+/**
+ * Get adapter functions for a specific platform
+ */
+export function getAdapters(platform: string) {
+  switch (platform.toLowerCase()) {
+    case "shopify":
+      return {
+        customer: adaptShopifyCustomer,
+        order: adaptShopifyOrder,
+        product: adaptShopifyProduct,
+      };
+    case "woocommerce":
+      return {
+        customer: adaptWooCommerceCustomer,
+        order: adaptWooCommerceOrder,
+        product: adaptWooCommerceProduct,
+      };
+    case "shoprenter":
+      return {
+        customer: adaptShopRenterCustomer,
+        order: adaptShopRenterOrder,
+        product: adaptShopRenterProduct,
+      };
+    default:
+      throw new Error(`Unsupported platform: ${platform}`);
+  }
+}

+ 487 - 0
supabase/functions/api-key-management/index.ts

@@ -0,0 +1,487 @@
+/**
+ * API Key Management - Create, list, revoke, and manage API keys
+ *
+ * Provides endpoints for users to manage their API keys for webshop-data-api access.
+ *
+ * Related: Issue #48 - GDPR-compliant webshop sync refactoring
+ */
+
+import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.7";
+import { wrapHandler } from "../_shared/error-handler.ts";
+import { generateApiKey, hashApiKey } from "../_shared/api-key-auth.ts";
+
+const FUNCTION_NAME = "api-key-management";
+
+interface CreateKeyRequest {
+  name: string;
+  expires_in_days?: number;
+}
+
+interface RevokeKeyRequest {
+  key_id: string;
+}
+
+interface RotateKeyRequest {
+  key_id: string;
+}
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+/**
+ * Get authenticated user from request
+ */
+async function getAuthenticatedUser(
+  req: Request,
+  supabase: any
+): Promise<{ user: any; error?: string }> {
+  const authHeader = req.headers.get("Authorization");
+
+  if (!authHeader) {
+    return { user: null, error: "Missing authorization header" };
+  }
+
+  const { data: { user }, error } = await supabase.auth.getUser(
+    authHeader.replace("Bearer ", "")
+  );
+
+  if (error || !user) {
+    return { user: null, error: "Invalid or expired token" };
+  }
+
+  return { user };
+}
+
+// ============================================================================
+// Route Handlers
+// ============================================================================
+
+/**
+ * POST /create - Create a new API key
+ */
+async function handleCreateKey(
+  req: Request,
+  supabaseAdmin: any,
+  userId: string
+): Promise<Response> {
+  const body: CreateKeyRequest = await req.json();
+
+  // Validate input
+  if (!body.name || body.name.trim().length < 3) {
+    return new Response(
+      JSON.stringify({
+        success: false,
+        error: "Key name must be at least 3 characters long",
+        code: "INVALID_NAME",
+      }),
+      { status: 400, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  if (body.name.length > 100) {
+    return new Response(
+      JSON.stringify({
+        success: false,
+        error: "Key name must be at most 100 characters long",
+        code: "NAME_TOO_LONG",
+      }),
+      { status: 400, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  // Check existing key count (limit to 10 keys per user)
+  const { count, error: countError } = await supabaseAdmin
+    .from("user_api_keys")
+    .select("*", { count: "exact", head: true })
+    .eq("user_id", userId)
+    .eq("is_active", true);
+
+  if (countError) {
+    console.error("Error counting API keys:", countError);
+    return new Response(
+      JSON.stringify({
+        success: false,
+        error: "Failed to check existing keys",
+        code: "DATABASE_ERROR",
+      }),
+      { status: 500, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  if (count && count >= 10) {
+    return new Response(
+      JSON.stringify({
+        success: false,
+        error: "Maximum API key limit reached (10 keys per user)",
+        code: "KEY_LIMIT_REACHED",
+      }),
+      { status: 429, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  // Generate new API key
+  const apiKey = generateApiKey();
+  const keyHash = await hashApiKey(apiKey);
+
+  // Calculate expiration date
+  let expiresAt: string | null = null;
+  if (body.expires_in_days && body.expires_in_days > 0) {
+    const expiryDate = new Date();
+    expiryDate.setDate(expiryDate.getDate() + body.expires_in_days);
+    expiresAt = expiryDate.toISOString();
+  }
+
+  // Insert into database
+  const { data: keyData, error: insertError } = await supabaseAdmin
+    .from("user_api_keys")
+    .insert({
+      user_id: userId,
+      key_name: body.name.trim(),
+      api_key: apiKey,
+      key_hash: keyHash,
+      permissions: { webshop_data: true },
+      expires_at: expiresAt,
+    })
+    .select()
+    .single();
+
+  if (insertError) {
+    console.error("Error creating API key:", insertError);
+    return new Response(
+      JSON.stringify({
+        success: false,
+        error: "Failed to create API key",
+        code: "CREATE_FAILED",
+        details: insertError.message,
+      }),
+      { status: 500, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  // Return the plaintext API key (only time it will be shown!)
+  return new Response(
+    JSON.stringify({
+      success: true,
+      message: "API key created successfully",
+      data: {
+        id: keyData.id,
+        name: keyData.key_name,
+        api_key: apiKey, // ONLY shown here!
+        expires_at: keyData.expires_at,
+        created_at: keyData.created_at,
+      },
+      warning: "Store this API key securely. It will not be shown again!",
+    }),
+    { status: 201, headers: { "Content-Type": "application/json" } }
+  );
+}
+
+/**
+ * GET /list - List all API keys for the user
+ */
+async function handleListKeys(
+  req: Request,
+  supabaseAdmin: any,
+  userId: string
+): Promise<Response> {
+  const { data: keys, error } = await supabaseAdmin
+    .from("user_api_keys")
+    .select("id, key_name, permissions, is_active, last_used_at, expires_at, created_at, updated_at")
+    .eq("user_id", userId)
+    .order("created_at", { ascending: false });
+
+  if (error) {
+    console.error("Error listing API keys:", error);
+    return new Response(
+      JSON.stringify({
+        success: false,
+        error: "Failed to list API keys",
+        code: "LIST_FAILED",
+      }),
+      { status: 500, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  // Add status information to each key
+  const keysWithStatus = keys.map((key: any) => {
+    let status = "active";
+    if (!key.is_active) {
+      status = "revoked";
+    } else if (key.expires_at && new Date(key.expires_at) < new Date()) {
+      status = "expired";
+    }
+
+    return {
+      ...key,
+      status,
+      // Mask the actual API key (we don't return it in list)
+      api_key_preview: "api_shopcall_****",
+    };
+  });
+
+  return new Response(
+    JSON.stringify({
+      success: true,
+      data: keysWithStatus,
+      count: keysWithStatus.length,
+    }),
+    { status: 200, headers: { "Content-Type": "application/json" } }
+  );
+}
+
+/**
+ * POST /revoke - Revoke an API key
+ */
+async function handleRevokeKey(
+  req: Request,
+  supabaseAdmin: any,
+  userId: string
+): Promise<Response> {
+  const body: RevokeKeyRequest = await req.json();
+
+  if (!body.key_id) {
+    return new Response(
+      JSON.stringify({
+        success: false,
+        error: "Missing key_id parameter",
+        code: "MISSING_KEY_ID",
+      }),
+      { status: 400, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  // Update the key to revoked status
+  const { data, error } = await supabaseAdmin
+    .from("user_api_keys")
+    .update({ is_active: false, updated_at: new Date().toISOString() })
+    .eq("id", body.key_id)
+    .eq("user_id", userId)
+    .select()
+    .single();
+
+  if (error || !data) {
+    console.error("Error revoking API key:", error);
+    return new Response(
+      JSON.stringify({
+        success: false,
+        error: "Failed to revoke API key. Key not found or access denied.",
+        code: "REVOKE_FAILED",
+      }),
+      { status: 404, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  return new Response(
+    JSON.stringify({
+      success: true,
+      message: "API key revoked successfully",
+      data: {
+        id: data.id,
+        name: data.key_name,
+        is_active: data.is_active,
+      },
+    }),
+    { status: 200, headers: { "Content-Type": "application/json" } }
+  );
+}
+
+/**
+ * POST /rotate - Rotate an API key (revoke old, create new)
+ */
+async function handleRotateKey(
+  req: Request,
+  supabaseAdmin: any,
+  userId: string
+): Promise<Response> {
+  const body: RotateKeyRequest = await req.json();
+
+  if (!body.key_id) {
+    return new Response(
+      JSON.stringify({
+        success: false,
+        error: "Missing key_id parameter",
+        code: "MISSING_KEY_ID",
+      }),
+      { status: 400, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  // Fetch the old key
+  const { data: oldKey, error: fetchError } = await supabaseAdmin
+    .from("user_api_keys")
+    .select("*")
+    .eq("id", body.key_id)
+    .eq("user_id", userId)
+    .single();
+
+  if (fetchError || !oldKey) {
+    return new Response(
+      JSON.stringify({
+        success: false,
+        error: "API key not found or access denied",
+        code: "KEY_NOT_FOUND",
+      }),
+      { status: 404, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  // Generate new API key
+  const newApiKey = generateApiKey();
+  const newKeyHash = await hashApiKey(newApiKey);
+
+  // Start transaction (revoke old, create new)
+  // First, revoke the old key
+  const { error: revokeError } = await supabaseAdmin
+    .from("user_api_keys")
+    .update({ is_active: false, updated_at: new Date().toISOString() })
+    .eq("id", body.key_id)
+    .eq("user_id", userId);
+
+  if (revokeError) {
+    console.error("Error revoking old key during rotation:", revokeError);
+    return new Response(
+      JSON.stringify({
+        success: false,
+        error: "Failed to revoke old key",
+        code: "ROTATION_FAILED",
+      }),
+      { status: 500, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  // Create new key with same name and permissions
+  const { data: newKey, error: createError } = await supabaseAdmin
+    .from("user_api_keys")
+    .insert({
+      user_id: userId,
+      key_name: `${oldKey.key_name} (rotated)`,
+      api_key: newApiKey,
+      key_hash: newKeyHash,
+      permissions: oldKey.permissions,
+      expires_at: oldKey.expires_at,
+    })
+    .select()
+    .single();
+
+  if (createError) {
+    console.error("Error creating new key during rotation:", createError);
+    return new Response(
+      JSON.stringify({
+        success: false,
+        error: "Failed to create new key",
+        code: "ROTATION_FAILED",
+      }),
+      { status: 500, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  return new Response(
+    JSON.stringify({
+      success: true,
+      message: "API key rotated successfully",
+      data: {
+        old_key_id: oldKey.id,
+        new_key: {
+          id: newKey.id,
+          name: newKey.key_name,
+          api_key: newApiKey, // ONLY shown here!
+          expires_at: newKey.expires_at,
+          created_at: newKey.created_at,
+        },
+      },
+      warning: "Store this new API key securely. The old key has been revoked.",
+    }),
+    { status: 200, headers: { "Content-Type": "application/json" } }
+  );
+}
+
+// ============================================================================
+// Main Handler
+// ============================================================================
+
+Deno.serve(
+  wrapHandler(FUNCTION_NAME, async (req: Request): Promise<Response> => {
+    const url = new URL(req.url);
+
+    // Handle CORS preflight
+    if (req.method === "OPTIONS") {
+      return new Response(null, {
+        status: 204,
+        headers: {
+          "Access-Control-Allow-Origin": "*",
+          "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
+          "Access-Control-Allow-Headers": "Authorization, Content-Type",
+          "Access-Control-Max-Age": "86400",
+        },
+      });
+    }
+
+    // Initialize Supabase clients
+    const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
+    const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
+    const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
+
+    const supabase = createClient(supabaseUrl, supabaseAnonKey, {
+      global: {
+        headers: { Authorization: req.headers.get("Authorization") || "" },
+      },
+    });
+
+    const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);
+
+    // Authenticate user
+    const { user, error: authError } = await getAuthenticatedUser(
+      req,
+      supabase
+    );
+
+    if (authError || !user) {
+      return new Response(
+        JSON.stringify({
+          success: false,
+          error: authError || "Authentication required",
+          code: "UNAUTHORIZED",
+        }),
+        { status: 401, headers: { "Content-Type": "application/json" } }
+      );
+    }
+
+    const userId = user.id;
+
+    // Route based on action
+    const action = url.pathname.replace(/^\/api-key-management\/?/, "");
+
+    if (req.method === "POST" && action === "create") {
+      return await handleCreateKey(req, supabaseAdmin, userId);
+    }
+
+    if (req.method === "GET" && action === "list") {
+      return await handleListKeys(req, supabaseAdmin, userId);
+    }
+
+    if (req.method === "POST" && action === "revoke") {
+      return await handleRevokeKey(req, supabaseAdmin, userId);
+    }
+
+    if (req.method === "POST" && action === "rotate") {
+      return await handleRotateKey(req, supabaseAdmin, userId);
+    }
+
+    // Unknown action
+    return new Response(
+      JSON.stringify({
+        success: false,
+        error: "Unknown action",
+        code: "UNKNOWN_ACTION",
+        available_actions: {
+          "POST /create": "Create a new API key",
+          "GET /list": "List all API keys",
+          "POST /revoke": "Revoke an API key",
+          "POST /rotate": "Rotate an API key (revoke old, create new)",
+        },
+      }),
+      { status: 404, headers: { "Content-Type": "application/json" } }
+    );
+  })
+);

+ 437 - 0
supabase/functions/webshop-data-api/index.ts

@@ -0,0 +1,437 @@
+/**
+ * Webshop Data API - Unified REST API for E-commerce Data Access
+ *
+ * Provides real-time access to customer, order, and product data from
+ * connected webshops (Shopify, WooCommerce, ShopRenter) without storing
+ * personal data locally (GDPR compliant).
+ *
+ * Related: Issue #48 - GDPR-compliant webshop sync refactoring
+ *
+ * Authentication: API Key (Bearer token)
+ * Rate Limit: 100 requests/minute per API key
+ *
+ * Endpoints:
+ * - GET /webshop-data-api/customers?store_id={uuid}&page=1&limit=25
+ * - GET /webshop-data-api/customers/{id}?store_id={uuid}
+ * - GET /webshop-data-api/orders?store_id={uuid}&status=completed&page=1
+ * - GET /webshop-data-api/orders/{id}?store_id={uuid}
+ * - GET /webshop-data-api/products?store_id={uuid}&page=1
+ * - GET /webshop-data-api/products/{id}?store_id={uuid}
+ */
+
+import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.7";
+import { wrapHandler } from "../_shared/error-handler.ts";
+import {
+  requireApiKey,
+  createApiKeyErrorResponse,
+  addRateLimitHeaders,
+  extractApiKey,
+} from "../_shared/api-key-auth.ts";
+import { getAdapters } from "../_shared/platform-adapters.ts";
+
+// Import platform-specific clients
+import * as ShopifyClient from "../_shared/shopify-client.ts";
+import * as WooCommerceClient from "../_shared/woocommerce-client.ts";
+import * as ShopRenterClient from "../_shared/shoprenter-client.ts";
+
+const FUNCTION_NAME = "webshop-data-api";
+
+interface PaginationMeta {
+  page: number;
+  limit: number;
+  total?: number;
+  has_more?: boolean;
+}
+
+interface ApiResponse {
+  success: boolean;
+  platform: string;
+  data: unknown;
+  pagination?: PaginationMeta;
+  fetched_at: string;
+}
+
+/**
+ * Main handler function
+ */
+async function handler(req: Request): Promise<Response> {
+  const supabaseUrl = Deno.env.get("SUPABASE_URL");
+  const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
+
+  if (!supabaseUrl || !supabaseServiceKey) {
+    return new Response(
+      JSON.stringify({ error: "Server configuration error" }),
+      { status: 500, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);
+
+  // Only allow GET requests
+  if (req.method !== "GET") {
+    return new Response(
+      JSON.stringify({ error: "Method not allowed" }),
+      { status: 405, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  // Validate API key
+  const apiKeyResult = await requireApiKey(req, supabaseAdmin, "webshop_data");
+  if (!apiKeyResult.valid) {
+    return createApiKeyErrorResponse(apiKeyResult);
+  }
+
+  const userId = apiKeyResult.userId!;
+  const apiKey = extractApiKey(req)!;
+
+  // Parse URL and query parameters
+  const url = new URL(req.url);
+  const pathParts = url.pathname.split("/").filter((p) => p);
+
+  // Expected path: /webshop-data-api/{resource}/{id?}
+  if (pathParts[0] !== "webshop-data-api") {
+    return new Response(
+      JSON.stringify({ error: "Invalid endpoint" }),
+      { status: 404, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  const resource = pathParts[1]; // 'customers', 'orders', or 'products'
+  const resourceId = pathParts[2]; // Optional ID
+
+  // Get query parameters
+  const storeId = url.searchParams.get("store_id");
+  const page = parseInt(url.searchParams.get("page") || "1");
+  const limit = parseInt(url.searchParams.get("limit") || "25");
+  const status = url.searchParams.get("status"); // For orders
+
+  // Validate required parameters
+  if (!storeId) {
+    return new Response(
+      JSON.stringify({
+        error: "Missing required parameter: store_id",
+        code: "MISSING_STORE_ID",
+      }),
+      { status: 400, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  if (!["customers", "orders", "products"].includes(resource)) {
+    return new Response(
+      JSON.stringify({
+        error: "Invalid resource. Must be customers, orders, or products",
+        code: "INVALID_RESOURCE",
+      }),
+      { status: 400, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  // Validate pagination parameters
+  if (page < 1 || limit < 1 || limit > 100) {
+    return new Response(
+      JSON.stringify({
+        error: "Invalid pagination parameters. Page must be >= 1, limit must be 1-100",
+        code: "INVALID_PAGINATION",
+      }),
+      { status: 400, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  // Fetch store and verify ownership
+  const { data: store, error: storeError } = await supabaseAdmin
+    .from("stores")
+    .select("*")
+    .eq("id", storeId)
+    .eq("user_id", userId)
+    .eq("is_active", true)
+    .single();
+
+  if (storeError || !store) {
+    return new Response(
+      JSON.stringify({
+        error: "Store not found or access denied",
+        code: "STORE_NOT_FOUND",
+      }),
+      { status: 404, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  // Check data access permissions
+  const permissions = store.data_access_permissions || {};
+  const dataType = resource.slice(0, -1); // Remove 's' (customers -> customer)
+
+  if (!permissions[`allow_${dataType}_access`]) {
+    return new Response(
+      JSON.stringify({
+        error: `Access to ${resource} is not enabled for this store`,
+        code: "ACCESS_DENIED",
+        hint: "Enable access in store settings",
+      }),
+      { status: 403, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  // Fetch data based on resource and platform
+  let responseData: ApiResponse;
+
+  try {
+    switch (store.platform_name.toLowerCase()) {
+      case "shopify":
+        responseData = await fetchShopifyData(
+          store,
+          resource,
+          resourceId,
+          page,
+          limit,
+          status
+        );
+        break;
+      case "woocommerce":
+        responseData = await fetchWooCommerceData(
+          store,
+          resource,
+          resourceId,
+          page,
+          limit,
+          status
+        );
+        break;
+      case "shoprenter":
+        responseData = await fetchShopRenterData(
+          store,
+          resource,
+          resourceId,
+          page,
+          limit,
+          status
+        );
+        break;
+      default:
+        return new Response(
+          JSON.stringify({
+            error: `Unsupported platform: ${store.platform_name}`,
+            code: "UNSUPPORTED_PLATFORM",
+          }),
+          { status: 400, headers: { "Content-Type": "application/json" } }
+        );
+    }
+
+    const response = new Response(JSON.stringify(responseData), {
+      status: 200,
+      headers: { "Content-Type": "application/json" },
+    });
+
+    // Add rate limit headers
+    return addRateLimitHeaders(response, apiKey);
+  } catch (error) {
+    console.error(`Error fetching ${resource}:`, error);
+
+    return new Response(
+      JSON.stringify({
+        error: error instanceof Error ? error.message : "Failed to fetch data",
+        code: "FETCH_ERROR",
+      }),
+      { status: 500, headers: { "Content-Type": "application/json" } }
+    );
+  }
+}
+
+/**
+ * Fetch data from Shopify
+ */
+async function fetchShopifyData(
+  store: any,
+  resource: string,
+  resourceId: string | undefined,
+  page: number,
+  limit: number,
+  status: string | null
+): Promise<ApiResponse> {
+  const adapters = getAdapters("shopify");
+
+  if (resourceId) {
+    // Fetch single item
+    const itemId = parseInt(resourceId);
+    let data;
+
+    if (resource === "customers") {
+      const response = await ShopifyClient.shopifyApiRequest(
+        store.id,
+        `/customers/${itemId}.json`
+      );
+      data = adapters.customer(response.customer);
+    } else if (resource === "orders") {
+      const response = await ShopifyClient.shopifyApiRequest(
+        store.id,
+        `/orders/${itemId}.json`
+      );
+      data = adapters.order(response.order);
+    } else if (resource === "products") {
+      const response = await ShopifyClient.shopifyApiRequest(
+        store.id,
+        `/products/${itemId}.json`
+      );
+      data = adapters.product(response.product);
+    }
+
+    return {
+      success: true,
+      platform: "shopify",
+      data,
+      fetched_at: new Date().toISOString(),
+    };
+  } else {
+    // Fetch list
+    const sinceId = page > 1 ? undefined : undefined; // Shopify uses since_id for pagination
+    let data;
+    let rawData;
+
+    if (resource === "customers") {
+      rawData = await ShopifyClient.fetchCustomers(store.id, limit, sinceId);
+      data = rawData.map((item: any) => adapters.customer(item));
+    } else if (resource === "orders") {
+      let endpoint = `/orders.json?limit=${limit}&status=${status || "any"}`;
+      const response = await ShopifyClient.shopifyApiRequest(store.id, endpoint);
+      rawData = response.orders || [];
+      data = rawData.map((item: any) => adapters.order(item));
+    } else if (resource === "products") {
+      rawData = await ShopifyClient.fetchProducts(store.id, limit, sinceId);
+      data = rawData.map((item: any) => adapters.product(item));
+    }
+
+    return {
+      success: true,
+      platform: "shopify",
+      data,
+      pagination: {
+        page,
+        limit,
+        has_more: (rawData?.length || 0) === limit,
+      },
+      fetched_at: new Date().toISOString(),
+    };
+  }
+}
+
+/**
+ * Fetch data from WooCommerce
+ */
+async function fetchWooCommerceData(
+  store: any,
+  resource: string,
+  resourceId: string | undefined,
+  page: number,
+  limit: number,
+  status: string | null
+): Promise<ApiResponse> {
+  const adapters = getAdapters("woocommerce");
+
+  if (resourceId) {
+    // Fetch single item
+    const itemId = parseInt(resourceId);
+    let data;
+
+    if (resource === "customers") {
+      const rawData = await WooCommerceClient.fetchCustomer(store.id, itemId);
+      data = adapters.customer(rawData);
+    } else if (resource === "orders") {
+      const rawData = await WooCommerceClient.fetchOrder(store.id, itemId);
+      data = adapters.order(rawData);
+    } else if (resource === "products") {
+      const rawData = await WooCommerceClient.fetchProduct(store.id, itemId);
+      data = adapters.product(rawData);
+    }
+
+    return {
+      success: true,
+      platform: "woocommerce",
+      data,
+      fetched_at: new Date().toISOString(),
+    };
+  } else {
+    // Fetch list
+    let data;
+    let rawData;
+
+    if (resource === "customers") {
+      rawData = await WooCommerceClient.fetchCustomers(store.id, page, limit);
+      data = rawData.map((item: any) => adapters.customer(item));
+    } else if (resource === "orders") {
+      rawData = await WooCommerceClient.fetchOrders(
+        store.id,
+        page,
+        limit,
+        status || undefined
+      );
+      data = rawData.map((item: any) => adapters.order(item));
+    } else if (resource === "products") {
+      rawData = await WooCommerceClient.fetchProducts(store.id, page, limit);
+      data = rawData.map((item: any) => adapters.product(item));
+    }
+
+    return {
+      success: true,
+      platform: "woocommerce",
+      data,
+      pagination: {
+        page,
+        limit,
+        has_more: (rawData?.length || 0) === limit,
+      },
+      fetched_at: new Date().toISOString(),
+    };
+  }
+}
+
+/**
+ * Fetch data from ShopRenter
+ */
+async function fetchShopRenterData(
+  store: any,
+  resource: string,
+  resourceId: string | undefined,
+  page: number,
+  limit: number,
+  status: string | null
+): Promise<ApiResponse> {
+  const adapters = getAdapters("shoprenter");
+
+  if (resourceId) {
+    // Fetch single item - ShopRenter client doesn't have individual fetch methods
+    // So we'll fetch the list and filter (or implement individual fetches)
+    throw new Error("Single item fetch not yet implemented for ShopRenter");
+  } else {
+    // Fetch list
+    let data;
+    let rawData;
+
+    if (resource === "customers") {
+      const response = await ShopRenterClient.fetchCustomers(store.id, page, limit);
+      rawData = response.items || [];
+      data = rawData.map((item: any) => adapters.customer(item));
+    } else if (resource === "orders") {
+      const response = await ShopRenterClient.fetchOrders(store.id, page, limit);
+      rawData = response.items || [];
+      data = rawData.map((item: any) => adapters.order(item));
+    } else if (resource === "products") {
+      const response = await ShopRenterClient.fetchProducts(store.id, page, limit);
+      rawData = response.items || [];
+      data = rawData.map((item: any) => adapters.product(item));
+    }
+
+    return {
+      success: true,
+      platform: "shoprenter",
+      data,
+      pagination: {
+        page,
+        limit,
+        has_more: (rawData?.length || 0) === limit,
+      },
+      fetched_at: new Date().toISOString(),
+    };
+  }
+}
+
+// Wrap handler with error handling
+Deno.serve(wrapHandler(FUNCTION_NAME, handler));

+ 12 - 18
supabase/functions/woocommerce-scheduled-sync/index.ts

@@ -59,12 +59,11 @@ serve(wrapHandler('woocommerce-scheduled-sync', async (req) => {
         store_name,
         store_name,
         store_url,
         store_url,
         alt_data,
         alt_data,
+        data_access_permissions,
         store_sync_config (
         store_sync_config (
           enabled,
           enabled,
           sync_frequency,
           sync_frequency,
           sync_products,
           sync_products,
-          sync_orders,
-          sync_customers,
           last_sync_at,
           last_sync_at,
           next_sync_at
           next_sync_at
         )
         )
@@ -131,8 +130,8 @@ serve(wrapHandler('woocommerce-scheduled-sync', async (req) => {
         store_id: storeId,
         store_id: storeId,
         store_name: store.store_name,
         store_name: store.store_name,
         products: { synced: 0, errors: 0 },
         products: { synced: 0, errors: 0 },
-        orders: { synced: 0, errors: 0 },
-        customers: { synced: 0, errors: 0 },
+        customer_access_tested: false,
+        order_access_tested: false,
         started_at: new Date().toISOString(),
         started_at: new Date().toISOString(),
         completed_at: null as string | null,
         completed_at: null as string | null,
         status: 'success' as 'success' | 'partial' | 'failed',
         status: 'success' as 'success' | 'partial' | 'failed',
@@ -140,13 +139,9 @@ serve(wrapHandler('woocommerce-scheduled-sync', async (req) => {
       }
       }
 
 
       try {
       try {
-        // Determine what to sync based on store config
-        const syncTypes = []
-        if (config?.sync_products !== false) syncTypes.push('products')
-        if (config?.sync_orders !== false) syncTypes.push('orders')
-        if (config?.sync_customers !== false) syncTypes.push('customers')
-
-        const syncType = syncTypes.length > 0 ? 'all' : 'products'
+        // Only sync products now (GDPR compliance - Issue #48)
+        // Customer and order data are accessed in real-time via webshop-data-api
+        const syncType = 'products'
 
 
         // Call woocommerce-sync Edge Function
         // Call woocommerce-sync Edge Function
         const syncResponse = await fetch(`${supabaseUrl}/functions/v1/woocommerce-sync`, {
         const syncResponse = await fetch(`${supabaseUrl}/functions/v1/woocommerce-sync`, {
@@ -170,16 +165,15 @@ serve(wrapHandler('woocommerce-scheduled-sync', async (req) => {
 
 
         if (syncResult.success && syncResult.stats) {
         if (syncResult.success && syncResult.stats) {
           syncStats.products = syncResult.stats.products || { synced: 0, errors: 0 }
           syncStats.products = syncResult.stats.products || { synced: 0, errors: 0 }
-          syncStats.orders = syncResult.stats.orders || { synced: 0, errors: 0 }
-          syncStats.customers = syncResult.stats.customers || { synced: 0, errors: 0 }
 
 
           // Determine status based on errors
           // Determine status based on errors
-          const hasErrors = (
-            syncStats.products.errors > 0 ||
-            syncStats.orders.errors > 0 ||
-            syncStats.customers.errors > 0
-          )
+          const hasErrors = syncStats.products.errors > 0
           syncStats.status = hasErrors ? 'partial' : 'success'
           syncStats.status = hasErrors ? 'partial' : 'success'
+
+          // Test data access permissions (don't sync, just validate)
+          const permissions = store.data_access_permissions || {}
+          syncStats.customer_access_tested = permissions.allow_customer_access || false
+          syncStats.order_access_tested = permissions.allow_order_access || false
         } else {
         } else {
           syncStats.status = 'failed'
           syncStats.status = 'failed'
           syncStats.error_message = syncResult.error || 'Unknown error'
           syncStats.error_message = syncResult.error || 'Unknown error'

+ 184 - 0
supabase/migrations/20251031_160100_api_keys_table.sql

@@ -0,0 +1,184 @@
+-- Migration: API Keys Table for Secure Data Access
+-- Description: Creates user_api_keys table for secure REST API authentication
+-- Date: 2025-10-31
+-- Related: Issue #48 - GDPR-compliant webshop sync refactoring
+
+-- ============================================================================
+-- STEP 1: Create user_api_keys Table
+-- ============================================================================
+
+CREATE TABLE IF NOT EXISTS user_api_keys (
+  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+  user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+  key_name TEXT NOT NULL,
+  api_key TEXT NOT NULL UNIQUE,
+  key_hash TEXT NOT NULL, -- bcrypt hash for validation (generated in application layer)
+  permissions JSONB NOT NULL DEFAULT '{"webshop_data": true}'::jsonb,
+  is_active BOOLEAN NOT NULL DEFAULT true,
+  last_used_at TIMESTAMPTZ,
+  expires_at TIMESTAMPTZ,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+  -- Constraints
+  CONSTRAINT key_name_length CHECK (char_length(key_name) >= 3 AND char_length(key_name) <= 100),
+  CONSTRAINT api_key_format CHECK (api_key LIKE 'api_shopcall_%')
+);
+
+-- Create indexes for performance
+CREATE INDEX idx_user_api_keys_user_id ON user_api_keys(user_id);
+CREATE INDEX idx_user_api_keys_api_key ON user_api_keys(api_key);
+CREATE INDEX idx_user_api_keys_is_active ON user_api_keys(is_active);
+CREATE INDEX idx_user_api_keys_expires_at ON user_api_keys(expires_at) WHERE expires_at IS NOT NULL;
+
+-- ============================================================================
+-- STEP 2: Add Updated Timestamp Trigger
+-- ============================================================================
+
+CREATE OR REPLACE FUNCTION update_user_api_keys_timestamp()
+RETURNS TRIGGER AS $$
+BEGIN
+  NEW.updated_at = NOW();
+  RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trigger_user_api_keys_updated_at
+  BEFORE UPDATE ON user_api_keys
+  FOR EACH ROW
+  EXECUTE FUNCTION update_user_api_keys_timestamp();
+
+-- ============================================================================
+-- STEP 3: Enable Row Level Security (RLS)
+-- ============================================================================
+
+ALTER TABLE user_api_keys ENABLE ROW LEVEL SECURITY;
+
+-- Policy: Users can view their own API keys
+CREATE POLICY "Users can view own API keys"
+  ON user_api_keys
+  FOR SELECT
+  USING (auth.uid() = user_id);
+
+-- Policy: Users can create their own API keys
+CREATE POLICY "Users can create own API keys"
+  ON user_api_keys
+  FOR INSERT
+  WITH CHECK (auth.uid() = user_id);
+
+-- Policy: Users can update their own API keys
+CREATE POLICY "Users can update own API keys"
+  ON user_api_keys
+  FOR UPDATE
+  USING (auth.uid() = user_id)
+  WITH CHECK (auth.uid() = user_id);
+
+-- Policy: Users can delete their own API keys
+CREATE POLICY "Users can delete own API keys"
+  ON user_api_keys
+  FOR DELETE
+  USING (auth.uid() = user_id);
+
+-- ============================================================================
+-- STEP 4: Create Helper Functions
+-- ============================================================================
+
+-- Function to check if an API key is valid and active
+CREATE OR REPLACE FUNCTION is_api_key_valid(
+  p_api_key TEXT
+)
+RETURNS TABLE (
+  valid BOOLEAN,
+  user_id UUID,
+  permissions JSONB,
+  error_message TEXT
+) AS $$
+BEGIN
+  RETURN QUERY
+  SELECT
+    CASE
+      WHEN k.id IS NULL THEN false
+      WHEN NOT k.is_active THEN false
+      WHEN k.expires_at IS NOT NULL AND k.expires_at < NOW() THEN false
+      ELSE true
+    END AS valid,
+    k.user_id,
+    k.permissions,
+    CASE
+      WHEN k.id IS NULL THEN 'API key not found'
+      WHEN NOT k.is_active THEN 'API key is inactive'
+      WHEN k.expires_at IS NOT NULL AND k.expires_at < NOW() THEN 'API key has expired'
+      ELSE NULL
+    END AS error_message
+  FROM user_api_keys k
+  WHERE k.api_key = p_api_key
+  LIMIT 1;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Function to update last_used_at timestamp
+CREATE OR REPLACE FUNCTION update_api_key_last_used(
+  p_api_key TEXT
+)
+RETURNS void AS $$
+BEGIN
+  UPDATE user_api_keys
+  SET last_used_at = NOW()
+  WHERE api_key = p_api_key AND is_active = true;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Function to revoke an API key
+CREATE OR REPLACE FUNCTION revoke_api_key(
+  p_key_id UUID,
+  p_user_id UUID
+)
+RETURNS BOOLEAN AS $$
+DECLARE
+  rows_affected INTEGER;
+BEGIN
+  UPDATE user_api_keys
+  SET is_active = false, updated_at = NOW()
+  WHERE id = p_key_id AND user_id = p_user_id;
+
+  GET DIAGNOSTICS rows_affected = ROW_COUNT;
+  RETURN rows_affected > 0;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Function to clean up expired API keys
+CREATE OR REPLACE FUNCTION cleanup_expired_api_keys()
+RETURNS INTEGER AS $$
+DECLARE
+  rows_deleted INTEGER;
+BEGIN
+  DELETE FROM user_api_keys
+  WHERE expires_at IS NOT NULL AND expires_at < NOW() - INTERVAL '30 days';
+
+  GET DIAGNOSTICS rows_deleted = ROW_COUNT;
+  RETURN rows_deleted;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- ============================================================================
+-- STEP 5: Comments for Documentation
+-- ============================================================================
+
+COMMENT ON TABLE user_api_keys IS 'Stores API keys for secure access to webshop data endpoints';
+COMMENT ON COLUMN user_api_keys.api_key IS 'Plain API key (format: api_shopcall_xxxxx)';
+COMMENT ON COLUMN user_api_keys.key_hash IS 'Bcrypt hash of API key for validation';
+COMMENT ON COLUMN user_api_keys.permissions IS 'JSON object defining what the API key can access';
+COMMENT ON COLUMN user_api_keys.last_used_at IS 'Timestamp of last successful API key usage';
+COMMENT ON COLUMN user_api_keys.expires_at IS 'Optional expiration date for the API key';
+
+-- ============================================================================
+-- STEP 6: Grant Necessary Permissions
+-- ============================================================================
+
+-- Grant usage on functions to authenticated users
+GRANT EXECUTE ON FUNCTION is_api_key_valid(TEXT) TO authenticated;
+GRANT EXECUTE ON FUNCTION update_api_key_last_used(TEXT) TO authenticated;
+GRANT EXECUTE ON FUNCTION revoke_api_key(UUID, UUID) TO authenticated;
+
+-- Service role can execute cleanup function
+GRANT EXECUTE ON FUNCTION cleanup_expired_api_keys() TO service_role;

+ 246 - 0
supabase/migrations/20251031_160200_data_access_permissions.sql

@@ -0,0 +1,246 @@
+-- Migration: Data Access Permissions for Stores
+-- Description: Adds data_access_permissions column to stores table for GDPR compliance
+-- Date: 2025-10-31
+-- Related: Issue #48 - GDPR-compliant webshop sync refactoring
+
+-- ============================================================================
+-- STEP 1: Add data_access_permissions Column to Stores Table
+-- ============================================================================
+
+ALTER TABLE stores ADD COLUMN IF NOT EXISTS data_access_permissions JSONB DEFAULT '{
+  "allow_customer_access": true,
+  "allow_order_access": true,
+  "allow_product_access": true
+}'::jsonb;
+
+-- Create GIN index for JSONB queries (efficient for searching within JSON)
+CREATE INDEX IF NOT EXISTS idx_stores_data_access ON stores USING gin(data_access_permissions);
+
+-- ============================================================================
+-- STEP 2: Update Existing Stores with Default Permissions
+-- ============================================================================
+
+-- Set default permissions for all existing stores (enabled by default for backward compatibility)
+UPDATE stores
+SET data_access_permissions = '{
+  "allow_customer_access": true,
+  "allow_order_access": true,
+  "allow_product_access": true
+}'::jsonb
+WHERE data_access_permissions IS NULL;
+
+-- ============================================================================
+-- STEP 3: Add Check Constraint
+-- ============================================================================
+
+-- Ensure data_access_permissions always contains required keys
+ALTER TABLE stores ADD CONSTRAINT data_access_permissions_structure
+  CHECK (
+    data_access_permissions ? 'allow_customer_access' AND
+    data_access_permissions ? 'allow_order_access' AND
+    data_access_permissions ? 'allow_product_access'
+  );
+
+-- ============================================================================
+-- STEP 4: Create Helper Functions
+-- ============================================================================
+
+-- Function to check if a user can access specific data type for a store
+CREATE OR REPLACE FUNCTION can_access_store_data(
+  p_user_id UUID,
+  p_store_id UUID,
+  p_data_type TEXT -- 'customer', 'order', or 'product'
+)
+RETURNS BOOLEAN AS $$
+DECLARE
+  store_record RECORD;
+  permission_key TEXT;
+BEGIN
+  -- Validate data_type parameter
+  IF p_data_type NOT IN ('customer', 'order', 'product') THEN
+    RAISE EXCEPTION 'Invalid data_type. Must be customer, order, or product';
+  END IF;
+
+  -- Build permission key
+  permission_key := 'allow_' || p_data_type || '_access';
+
+  -- Check if store belongs to user and has permission enabled
+  SELECT
+    s.id,
+    s.user_id,
+    s.is_active,
+    (s.data_access_permissions->permission_key)::boolean AS has_permission
+  INTO store_record
+  FROM stores s
+  WHERE s.id = p_store_id AND s.user_id = p_user_id AND s.is_active = true;
+
+  -- Return false if store not found or not active
+  IF NOT FOUND THEN
+    RETURN false;
+  END IF;
+
+  -- Return the permission value
+  RETURN COALESCE(store_record.has_permission, false);
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Function to update store data access permissions
+CREATE OR REPLACE FUNCTION update_store_data_access(
+  p_store_id UUID,
+  p_user_id UUID,
+  p_allow_customer_access BOOLEAN DEFAULT NULL,
+  p_allow_order_access BOOLEAN DEFAULT NULL,
+  p_allow_product_access BOOLEAN DEFAULT NULL
+)
+RETURNS BOOLEAN AS $$
+DECLARE
+  current_permissions JSONB;
+  updated_permissions JSONB;
+  rows_affected INTEGER;
+BEGIN
+  -- Get current permissions
+  SELECT data_access_permissions INTO current_permissions
+  FROM stores
+  WHERE id = p_store_id AND user_id = p_user_id;
+
+  IF NOT FOUND THEN
+    RAISE EXCEPTION 'Store not found or access denied';
+  END IF;
+
+  -- Build updated permissions (keep existing values if parameter is NULL)
+  updated_permissions := jsonb_build_object(
+    'allow_customer_access', COALESCE(p_allow_customer_access, (current_permissions->>'allow_customer_access')::boolean),
+    'allow_order_access', COALESCE(p_allow_order_access, (current_permissions->>'allow_order_access')::boolean),
+    'allow_product_access', COALESCE(p_allow_product_access, (current_permissions->>'allow_product_access')::boolean)
+  );
+
+  -- Update the store
+  UPDATE stores
+  SET
+    data_access_permissions = updated_permissions,
+    updated_at = NOW()
+  WHERE id = p_store_id AND user_id = p_user_id;
+
+  GET DIAGNOSTICS rows_affected = ROW_COUNT;
+  RETURN rows_affected > 0;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Function to get stores with specific data access enabled
+CREATE OR REPLACE FUNCTION get_stores_with_data_access(
+  p_user_id UUID,
+  p_data_type TEXT -- 'customer', 'order', or 'product'
+)
+RETURNS TABLE (
+  store_id UUID,
+  store_name TEXT,
+  platform_name TEXT,
+  store_url TEXT
+) AS $$
+DECLARE
+  permission_key TEXT;
+BEGIN
+  -- Validate data_type parameter
+  IF p_data_type NOT IN ('customer', 'order', 'product') THEN
+    RAISE EXCEPTION 'Invalid data_type. Must be customer, order, or product';
+  END IF;
+
+  -- Build permission key
+  permission_key := 'allow_' || p_data_type || '_access';
+
+  RETURN QUERY
+  SELECT
+    s.id AS store_id,
+    s.store_name,
+    s.platform_name,
+    s.store_url
+  FROM stores s
+  WHERE
+    s.user_id = p_user_id
+    AND s.is_active = true
+    AND (s.data_access_permissions->>permission_key)::boolean = true
+  ORDER BY s.store_name;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- ============================================================================
+-- STEP 5: Create Audit Function for Permission Changes
+-- ============================================================================
+
+-- Create audit table for tracking permission changes
+CREATE TABLE IF NOT EXISTS store_permission_audit (
+  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+  store_id UUID NOT NULL REFERENCES stores(id) ON DELETE CASCADE,
+  user_id UUID NOT NULL,
+  changed_by UUID REFERENCES auth.users(id),
+  old_permissions JSONB,
+  new_permissions JSONB,
+  change_reason TEXT,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_store_permission_audit_store_id ON store_permission_audit(store_id);
+CREATE INDEX idx_store_permission_audit_created_at ON store_permission_audit(created_at);
+
+-- Trigger to log permission changes
+CREATE OR REPLACE FUNCTION log_permission_change()
+RETURNS TRIGGER AS $$
+BEGIN
+  -- Only log if permissions actually changed
+  IF OLD.data_access_permissions IS DISTINCT FROM NEW.data_access_permissions THEN
+    INSERT INTO store_permission_audit (
+      store_id,
+      user_id,
+      changed_by,
+      old_permissions,
+      new_permissions,
+      change_reason
+    ) VALUES (
+      NEW.id,
+      NEW.user_id,
+      auth.uid(),
+      OLD.data_access_permissions,
+      NEW.data_access_permissions,
+      'Permission updated'
+    );
+  END IF;
+
+  RETURN NEW;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+CREATE TRIGGER trigger_log_permission_change
+  AFTER UPDATE ON stores
+  FOR EACH ROW
+  WHEN (OLD.data_access_permissions IS DISTINCT FROM NEW.data_access_permissions)
+  EXECUTE FUNCTION log_permission_change();
+
+-- ============================================================================
+-- STEP 6: Enable RLS on Audit Table
+-- ============================================================================
+
+ALTER TABLE store_permission_audit ENABLE ROW LEVEL SECURITY;
+
+-- Policy: Users can view audit logs for their own stores
+CREATE POLICY "Users can view own permission audit logs"
+  ON store_permission_audit
+  FOR SELECT
+  USING (user_id = auth.uid());
+
+-- ============================================================================
+-- STEP 7: Comments for Documentation
+-- ============================================================================
+
+COMMENT ON COLUMN stores.data_access_permissions IS 'JSONB object controlling access to customer, order, and product data';
+COMMENT ON TABLE store_permission_audit IS 'Audit log for tracking changes to store data access permissions';
+COMMENT ON FUNCTION can_access_store_data(UUID, UUID, TEXT) IS 'Checks if a user can access specific data type for a store';
+COMMENT ON FUNCTION update_store_data_access(UUID, UUID, BOOLEAN, BOOLEAN, BOOLEAN) IS 'Updates data access permissions for a store';
+COMMENT ON FUNCTION get_stores_with_data_access(UUID, TEXT) IS 'Returns stores where specific data type access is enabled';
+
+-- ============================================================================
+-- STEP 8: Grant Necessary Permissions
+-- ============================================================================
+
+GRANT EXECUTE ON FUNCTION can_access_store_data(UUID, UUID, TEXT) TO authenticated;
+GRANT EXECUTE ON FUNCTION update_store_data_access(UUID, UUID, BOOLEAN, BOOLEAN, BOOLEAN) TO authenticated;
+GRANT EXECUTE ON FUNCTION get_stores_with_data_access(UUID, TEXT) TO authenticated;

+ 197 - 0
supabase/migrations/20251031_160300_drop_customer_order_cache.sql

@@ -0,0 +1,197 @@
+-- Migration: Drop Customer and Order Cache Tables for GDPR Compliance
+-- Description: Removes persistent storage of personal data (customers and orders)
+-- Date: 2025-10-31
+-- Related: Issue #48 - GDPR-compliant webshop sync refactoring
+-- IMPORTANT: This migration will DELETE all cached customer and order data
+
+-- ============================================================================
+-- STEP 1: Backup Information (Run manually before applying if needed)
+-- ============================================================================
+
+-- If you need to backup data before dropping, run these queries manually:
+-- pg_dump -h host -U user -d database -t shopify_customers_cache > shopify_customers_backup.sql
+-- pg_dump -h host -U user -d database -t shopify_orders_cache > shopify_orders_backup.sql
+-- pg_dump -h host -U user -d database -t woocommerce_customers_cache > woocommerce_customers_backup.sql
+-- pg_dump -h host -U user -d database -t woocommerce_orders_cache > woocommerce_orders_backup.sql
+-- pg_dump -h host -U user -d database -t shoprenter_customers_cache > shoprenter_customers_backup.sql
+-- pg_dump -h host -U user -d database -t shoprenter_orders_cache > shoprenter_orders_backup.sql
+
+-- ============================================================================
+-- STEP 2: Log Table Sizes Before Deletion (for auditing)
+-- ============================================================================
+
+DO $$
+DECLARE
+  table_stats TEXT;
+BEGIN
+  SELECT string_agg(
+    format('%s: %s rows', table_name, row_count),
+    ', '
+  ) INTO table_stats
+  FROM (
+    SELECT 'shopify_customers_cache' AS table_name,
+           (SELECT COUNT(*) FROM shopify_customers_cache) AS row_count
+    UNION ALL
+    SELECT 'shopify_orders_cache',
+           (SELECT COUNT(*) FROM shopify_orders_cache)
+    UNION ALL
+    SELECT 'woocommerce_customers_cache',
+           (SELECT COUNT(*) FROM woocommerce_customers_cache)
+    UNION ALL
+    SELECT 'woocommerce_orders_cache',
+           (SELECT COUNT(*) FROM woocommerce_orders_cache)
+    UNION ALL
+    SELECT 'shoprenter_customers_cache',
+           (SELECT COUNT(*) FROM shoprenter_customers_cache WHERE FALSE) -- Check if exists
+    UNION ALL
+    SELECT 'shoprenter_orders_cache',
+           (SELECT COUNT(*) FROM shoprenter_orders_cache WHERE FALSE) -- Check if exists
+  ) counts;
+
+  RAISE NOTICE 'Table sizes before deletion: %', table_stats;
+EXCEPTION
+  WHEN OTHERS THEN
+    RAISE NOTICE 'Could not log table sizes: %', SQLERRM;
+END $$;
+
+-- ============================================================================
+-- STEP 3: Drop Shopify Customer and Order Cache Tables
+-- ============================================================================
+
+-- Drop dependent views or materialized views if any
+DROP MATERIALIZED VIEW IF EXISTS shopify_customer_stats CASCADE;
+DROP VIEW IF EXISTS shopify_order_summary CASCADE;
+
+-- Drop triggers and functions associated with these tables
+DROP TRIGGER IF EXISTS trigger_shopify_customers_updated ON shopify_customers_cache;
+DROP TRIGGER IF EXISTS trigger_shopify_orders_updated ON shopify_orders_cache;
+DROP FUNCTION IF EXISTS update_shopify_customer_stats() CASCADE;
+DROP FUNCTION IF EXISTS update_shopify_order_stats() CASCADE;
+
+-- Drop the tables
+DROP TABLE IF EXISTS shopify_customers_cache CASCADE;
+DROP TABLE IF EXISTS shopify_orders_cache CASCADE;
+
+RAISE NOTICE 'Dropped Shopify customer and order cache tables';
+
+-- ============================================================================
+-- STEP 4: Drop WooCommerce Customer and Order Cache Tables
+-- ============================================================================
+
+-- Drop dependent views or materialized views if any
+DROP MATERIALIZED VIEW IF EXISTS woocommerce_customer_stats CASCADE;
+DROP VIEW IF EXISTS woocommerce_order_summary CASCADE;
+
+-- Drop triggers and functions associated with these tables
+DROP TRIGGER IF EXISTS trigger_woocommerce_customers_updated ON woocommerce_customers_cache;
+DROP TRIGGER IF EXISTS trigger_woocommerce_orders_updated ON woocommerce_orders_cache;
+DROP FUNCTION IF EXISTS update_woocommerce_customer_stats() CASCADE;
+DROP FUNCTION IF EXISTS update_woocommerce_order_stats() CASCADE;
+
+-- Drop the tables
+DROP TABLE IF EXISTS woocommerce_customers_cache CASCADE;
+DROP TABLE IF EXISTS woocommerce_orders_cache CASCADE;
+
+RAISE NOTICE 'Dropped WooCommerce customer and order cache tables';
+
+-- ============================================================================
+-- STEP 5: Drop ShopRenter Customer and Order Cache Tables
+-- ============================================================================
+
+-- Drop dependent views or materialized views if any
+DROP MATERIALIZED VIEW IF EXISTS shoprenter_customer_stats CASCADE;
+DROP VIEW IF EXISTS shoprenter_order_summary CASCADE;
+
+-- Drop triggers and functions associated with these tables
+DROP TRIGGER IF EXISTS trigger_shoprenter_customers_updated ON shoprenter_customers_cache;
+DROP TRIGGER IF EXISTS trigger_shoprenter_orders_updated ON shoprenter_orders_cache;
+DROP FUNCTION IF EXISTS update_shoprenter_customer_stats() CASCADE;
+DROP FUNCTION IF EXISTS update_shoprenter_order_stats() CASCADE;
+
+-- Drop the tables (if they exist)
+DROP TABLE IF EXISTS shoprenter_customers_cache CASCADE;
+DROP TABLE IF EXISTS shoprenter_orders_cache CASCADE;
+
+RAISE NOTICE 'Dropped ShopRenter customer and order cache tables';
+
+-- ============================================================================
+-- STEP 6: Update Sync Status Functions to Remove Customer/Order References
+-- ============================================================================
+
+-- Remove or update any functions that reference the dropped tables
+-- Note: Functions are updated in subsequent migrations or Edge Function changes
+
+-- Log a notice about updating related functions
+DO $$
+BEGIN
+  RAISE NOTICE 'Customer and order cache tables have been dropped for GDPR compliance';
+  RAISE NOTICE 'Update Edge Functions to remove customer/order sync logic';
+  RAISE NOTICE 'Customer and order data will now be accessed in real-time via API';
+END $$;
+
+-- ============================================================================
+-- STEP 7: Clean Up store_sync_config Table
+-- ============================================================================
+
+-- Remove sync_orders and sync_customers columns from store_sync_config
+ALTER TABLE store_sync_config DROP COLUMN IF EXISTS sync_orders CASCADE;
+ALTER TABLE store_sync_config DROP COLUMN IF EXISTS sync_customers CASCADE;
+
+-- Ensure sync_products column exists and is properly configured
+ALTER TABLE store_sync_config ALTER COLUMN sync_products SET DEFAULT true;
+
+RAISE NOTICE 'Updated store_sync_config table to remove customer/order sync flags';
+
+-- ============================================================================
+-- STEP 8: Update sync_logs Table Structure
+-- ============================================================================
+
+-- Add note field to track the change
+COMMENT ON TABLE sync_logs IS 'Sync execution logs - tracks product sync only (customer/order data removed for GDPR compliance)';
+
+-- ============================================================================
+-- STEP 9: Clean Up alt_data in Stores Table
+-- ============================================================================
+
+-- Remove customer/order sync statistics from stores.alt_data
+UPDATE stores
+SET alt_data = alt_data - 'last_customer_sync' - 'last_order_sync' - 'customer_count' - 'order_count'
+WHERE alt_data IS NOT NULL
+  AND (
+    alt_data ? 'last_customer_sync'
+    OR alt_data ? 'last_order_sync'
+    OR alt_data ? 'customer_count'
+    OR alt_data ? 'order_count'
+  );
+
+RAISE NOTICE 'Cleaned up alt_data in stores table';
+
+-- ============================================================================
+-- STEP 10: Summary and Final Notes
+-- ============================================================================
+
+DO $$
+BEGIN
+  RAISE NOTICE '=================================================================';
+  RAISE NOTICE 'GDPR Compliance Migration Complete';
+  RAISE NOTICE '=================================================================';
+  RAISE NOTICE 'Dropped Tables:';
+  RAISE NOTICE '  - shopify_customers_cache';
+  RAISE NOTICE '  - shopify_orders_cache';
+  RAISE NOTICE '  - woocommerce_customers_cache';
+  RAISE NOTICE '  - woocommerce_orders_cache';
+  RAISE NOTICE '  - shoprenter_customers_cache (if existed)';
+  RAISE NOTICE '  - shoprenter_orders_cache (if existed)';
+  RAISE NOTICE '';
+  RAISE NOTICE 'Kept Tables:';
+  RAISE NOTICE '  - shopify_products_cache';
+  RAISE NOTICE '  - woocommerce_products_cache';
+  RAISE NOTICE '  - shoprenter_products_cache';
+  RAISE NOTICE '';
+  RAISE NOTICE 'Next Steps:';
+  RAISE NOTICE '  1. Deploy updated Edge Functions (remove customer/order sync logic)';
+  RAISE NOTICE '  2. Deploy webshop-data-api Edge Function for real-time data access';
+  RAISE NOTICE '  3. Update frontend to use new real-time API endpoints';
+  RAISE NOTICE '  4. Test data access permissions';
+  RAISE NOTICE '=================================================================';
+END $$;