Data Models & Schema

Database entity relationships, table structures, and data model design for Smart Shelf.

Data Models & Schema

Comprehensive overview of Smart Shelf's database schema, entity relationships, and data model design patterns using PostgreSQL and Supabase.

Entity Relationship Model

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Users     │     │  Products   │     │ Categories  │
│             │     │             │     │             │
│ • id        │     │ • id        │────▶│ • id        │
│ • email     │     │ • name      │     │ • name      │
│ • role      │     │ • sku       │     │ • parent_id │
│ • profile   │     │ • category  │     │ • is_active │
└─────────────┘     │ • barcode   │     └─────────────┘
                    │ • price     │
                    │ • cost      │
                    └─────────────┘
                    ┌─────────────┐     ┌─────────────┐
                    │ Inventory   │     │ Warehouses  │
                    │             │     │             │
                    │ • product   │────▶│ • id        │
                    │ • warehouse │     │ • name      │
                    │ • quantity  │     │ • address   │
                    │ • allocated │     │ • capacity  │
                    │ • available │     │ • is_active │
                    └─────────────┘     └─────────────┘
                    ┌─────────────┐
                    │Stock Moves  │
                    │             │
                    │ • id        │
                    │ • product   │
                    │ • quantity  │
                    │ • type      │
                    │ • reason    │
                    │ • timestamp │
                    └─────────────┘

Core Table Structures

1. Authentication & User Management

auth.users (Supabase Auth)

-- Managed by Supabase Auth
CREATE TABLE auth.users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email VARCHAR(255) UNIQUE NOT NULL,
  encrypted_password VARCHAR(255),
  email_confirmed_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

user_profiles

CREATE TABLE user_profiles (
  id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
  full_name VARCHAR(255) NOT NULL,
  avatar_url TEXT,
  role user_role NOT NULL DEFAULT 'staff',
  warehouse_id UUID REFERENCES warehouses(id),
  is_active BOOLEAN DEFAULT true,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Create user role enum
CREATE TYPE user_role AS ENUM ('admin', 'manager', 'staff', 'viewer');

user_roles

CREATE TABLE user_roles (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES user_profiles(id) ON DELETE CASCADE,
  role VARCHAR(50) NOT NULL,
  permissions JSONB DEFAULT '{}',
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(user_id, role)
);

2. Product Management

products

CREATE TABLE products (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL,
  description TEXT,
  sku VARCHAR(100) UNIQUE NOT NULL,
  category_id UUID REFERENCES product_categories(id),
  brand VARCHAR(100),
  unit_of_measure unit_type NOT NULL DEFAULT 'piece',
  cost_price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
  selling_price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
  min_stock_level INTEGER DEFAULT 0,
  max_stock_level INTEGER,
  is_active BOOLEAN DEFAULT true,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Create unit of measure enum
CREATE TYPE unit_type AS ENUM ('piece', 'kg', 'gram', 'liter', 'ml', 'box', 'pack', 'dozen');

-- Indexes for performance
CREATE INDEX idx_products_sku ON products(sku);
CREATE INDEX idx_products_category ON products(category_id);
CREATE INDEX idx_products_active ON products(name) WHERE is_active = true;

product_categories

CREATE TABLE product_categories (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL,
  description TEXT,
  parent_id UUID REFERENCES product_categories(id),
  sort_order INTEGER DEFAULT 0,
  is_active BOOLEAN DEFAULT true,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  
  -- Prevent circular references
  CONSTRAINT no_self_parent CHECK (id != parent_id)
);

-- Index for hierarchical queries
CREATE INDEX idx_categories_parent ON product_categories(parent_id);
CREATE INDEX idx_categories_active ON product_categories(name) WHERE is_active = true;

product_barcodes

CREATE TABLE product_barcodes (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
  barcode VARCHAR(50) UNIQUE NOT NULL,
  barcode_type barcode_type NOT NULL DEFAULT 'EAN13',
  is_primary BOOLEAN DEFAULT false,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Create barcode type enum
CREATE TYPE barcode_type AS ENUM ('UPC', 'EAN8', 'EAN13', 'CODE128', 'CODE39', 'QR');

-- Ensure only one primary barcode per product
CREATE UNIQUE INDEX idx_product_primary_barcode 
ON product_barcodes(product_id) 
WHERE is_primary = true;

product_variants

CREATE TABLE product_variants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
  name VARCHAR(255) NOT NULL,
  sku VARCHAR(100) UNIQUE NOT NULL,
  cost_price DECIMAL(10,2),
  selling_price DECIMAL(10,2),
  attributes JSONB DEFAULT '{}',
  is_active BOOLEAN DEFAULT true,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Index for variant attributes
CREATE INDEX idx_variant_attributes ON product_variants USING GIN(attributes);

3. Inventory Management

inventory

CREATE TABLE inventory (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
  warehouse_id UUID NOT NULL REFERENCES warehouses(id) ON DELETE CASCADE,
  quantity_on_hand INTEGER NOT NULL DEFAULT 0,
  quantity_allocated INTEGER NOT NULL DEFAULT 0,
  quantity_available INTEGER GENERATED ALWAYS AS (quantity_on_hand - quantity_allocated) STORED,
  last_counted TIMESTAMPTZ,
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  
  -- Ensure positive quantities
  CONSTRAINT positive_on_hand CHECK (quantity_on_hand >= 0),
  CONSTRAINT positive_allocated CHECK (quantity_allocated >= 0),
  
  -- Unique product per warehouse
  UNIQUE(product_id, warehouse_id)
);

-- Indexes for performance
CREATE INDEX idx_inventory_product ON inventory(product_id);
CREATE INDEX idx_inventory_warehouse ON inventory(warehouse_id);
CREATE INDEX idx_inventory_low_stock ON inventory(product_id, quantity_available) 
WHERE quantity_available <= 10;

stock_movements

CREATE TABLE stock_movements (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  product_id UUID NOT NULL REFERENCES products(id),
  warehouse_id UUID NOT NULL REFERENCES warehouses(id),
  movement_type movement_type NOT NULL,
  quantity INTEGER NOT NULL,
  unit_cost DECIMAL(10,2),
  reference_id UUID,
  reference_type VARCHAR(50),
  reason VARCHAR(255),
  notes TEXT,
  created_by UUID NOT NULL REFERENCES user_profiles(id),
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Create movement type enum
CREATE TYPE movement_type AS ENUM ('IN', 'OUT', 'TRANSFER', 'ADJUSTMENT');

-- Indexes for reporting and queries
CREATE INDEX idx_stock_movements_product_date ON stock_movements(product_id, created_at DESC);
CREATE INDEX idx_stock_movements_warehouse_date ON stock_movements(warehouse_id, created_at DESC);
CREATE INDEX idx_stock_movements_type ON stock_movements(movement_type);
CREATE INDEX idx_stock_movements_reference ON stock_movements(reference_id, reference_type);

inventory_adjustments

CREATE TABLE inventory_adjustments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  product_id UUID NOT NULL REFERENCES products(id),
  warehouse_id UUID NOT NULL REFERENCES warehouses(id),
  quantity_before INTEGER NOT NULL,
  quantity_after INTEGER NOT NULL,
  adjustment_quantity INTEGER GENERATED ALWAYS AS (quantity_after - quantity_before) STORED,
  reason adjustment_reason NOT NULL,
  notes TEXT,
  approved_by UUID REFERENCES user_profiles(id),
  created_by UUID NOT NULL REFERENCES user_profiles(id),
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Create adjustment reason enum
CREATE TYPE adjustment_reason AS ENUM (
  'physical_count', 'damage', 'expired', 'theft', 'found', 'correction', 'other'
);

4. Warehouse Management

warehouses

CREATE TABLE warehouses (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL,
  code VARCHAR(20) UNIQUE NOT NULL,
  address JSONB,
  contact_info JSONB,
  capacity INTEGER,
  is_active BOOLEAN DEFAULT true,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Index for active warehouses
CREATE INDEX idx_warehouses_active ON warehouses(name) WHERE is_active = true;

warehouse_locations

CREATE TABLE warehouse_locations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  warehouse_id UUID NOT NULL REFERENCES warehouses(id) ON DELETE CASCADE,
  location_code VARCHAR(50) NOT NULL,
  location_type location_type NOT NULL,
  parent_location_id UUID REFERENCES warehouse_locations(id),
  capacity INTEGER,
  is_active BOOLEAN DEFAULT true,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  
  -- Unique location code within warehouse
  UNIQUE(warehouse_id, location_code)
);

-- Create location type enum
CREATE TYPE location_type AS ENUM ('AISLE', 'RACK', 'SHELF', 'BIN');

Validation and Constraints

Data Integrity Rules

-- Function to validate stock quantities
CREATE OR REPLACE FUNCTION validate_stock_movement()
RETURNS TRIGGER AS $$
BEGIN
  -- Validate OUT and TRANSFER movements don't exceed available stock
  IF NEW.movement_type IN ('OUT', 'TRANSFER') THEN
    IF NEW.quantity > (
      SELECT quantity_available 
      FROM inventory 
      WHERE product_id = NEW.product_id 
        AND warehouse_id = NEW.warehouse_id
    ) THEN
      RAISE EXCEPTION 'Insufficient stock for movement. Available: %, Requested: %', 
        (SELECT quantity_available FROM inventory 
         WHERE product_id = NEW.product_id AND warehouse_id = NEW.warehouse_id), 
        NEW.quantity;
    END IF;
  END IF;
  
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Apply validation trigger
CREATE TRIGGER validate_stock_movement_trigger
  BEFORE INSERT ON stock_movements
  FOR EACH ROW
  EXECUTE FUNCTION validate_stock_movement();

Audit Trail

-- Generic audit trail table
CREATE TABLE audit_log (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  table_name VARCHAR(50) NOT NULL,
  record_id UUID NOT NULL,
  operation VARCHAR(10) NOT NULL, -- INSERT, UPDATE, DELETE
  old_values JSONB,
  new_values JSONB,
  changed_by UUID REFERENCES user_profiles(id),
  changed_at TIMESTAMPTZ DEFAULT NOW()
);

-- Audit function
CREATE OR REPLACE FUNCTION audit_trigger_function()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO audit_log (
    table_name,
    record_id,
    operation,
    old_values,
    new_values,
    changed_by
  ) VALUES (
    TG_TABLE_NAME,
    COALESCE(NEW.id, OLD.id),
    TG_OP,
    CASE WHEN TG_OP IN ('UPDATE', 'DELETE') THEN to_jsonb(OLD) END,
    CASE WHEN TG_OP IN ('INSERT', 'UPDATE') THEN to_jsonb(NEW) END,
    COALESCE(NEW.updated_by, OLD.updated_by, current_setting('app.current_user_id', true)::UUID)
  );
  
  RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;

Database Functions and Views

Inventory Summary View

CREATE VIEW inventory_summary AS
SELECT 
  i.product_id,
  p.name AS product_name,
  p.sku,
  i.warehouse_id,
  w.name AS warehouse_name,
  i.quantity_on_hand,
  i.quantity_allocated,
  i.quantity_available,
  p.min_stock_level,
  CASE 
    WHEN i.quantity_available <= 0 THEN 'OUT_OF_STOCK'
    WHEN i.quantity_available <= p.min_stock_level THEN 'LOW_STOCK'
    ELSE 'IN_STOCK'
  END AS stock_status,
  i.last_counted,
  i.updated_at
FROM inventory i
JOIN products p ON i.product_id = p.id
JOIN warehouses w ON i.warehouse_id = w.id
WHERE p.is_active = true AND w.is_active = true;

Stock Movement Summary Function

CREATE OR REPLACE FUNCTION get_stock_movements(
  p_product_id UUID DEFAULT NULL,
  p_warehouse_id UUID DEFAULT NULL,
  p_from_date TIMESTAMPTZ DEFAULT NULL,
  p_to_date TIMESTAMPTZ DEFAULT NULL,
  p_limit INTEGER DEFAULT 50
)
RETURNS TABLE (
  id UUID,
  product_name VARCHAR,
  warehouse_name VARCHAR,
  movement_type movement_type,
  quantity INTEGER,
  created_at TIMESTAMPTZ,
  created_by_name VARCHAR,
  reason VARCHAR,
  running_total BIGINT
) AS $$
BEGIN
  RETURN QUERY
  SELECT 
    sm.id,
    p.name AS product_name,
    w.name AS warehouse_name,
    sm.movement_type,
    sm.quantity,
    sm.created_at,
    up.full_name AS created_by_name,
    sm.reason,
    SUM(
      CASE 
        WHEN sm.movement_type IN ('IN', 'TRANSFER') THEN sm.quantity
        ELSE -sm.quantity
      END
    ) OVER (
      PARTITION BY sm.product_id, sm.warehouse_id 
      ORDER BY sm.created_at ASC 
      ROWS UNBOUNDED PRECEDING
    ) AS running_total
  FROM stock_movements sm
  JOIN products p ON sm.product_id = p.id
  JOIN warehouses w ON sm.warehouse_id = w.id
  JOIN user_profiles up ON sm.created_by = up.id
  WHERE 
    (p_product_id IS NULL OR sm.product_id = p_product_id)
    AND (p_warehouse_id IS NULL OR sm.warehouse_id = p_warehouse_id)
    AND (p_from_date IS NULL OR sm.created_at >= p_from_date)
    AND (p_to_date IS NULL OR sm.created_at <= p_to_date)
  ORDER BY sm.created_at DESC
  LIMIT p_limit;
END;
$$ LANGUAGE plpgsql;

This comprehensive data model ensures data integrity, performance, and scalability for Smart Shelf's inventory management requirements.