[SAAS-019] feat: Add Portfolio module DDL

- Schema creation and grants
- Enums: product_type, product_status, price_type, attribute_type
- Tables: categories, products, variants, prices
- RLS policies for tenant isolation
- Performance indexes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 05:43:02 -06:00
parent 8915b7ce71
commit a3f354528a
5 changed files with 526 additions and 0 deletions

View File

@ -0,0 +1,19 @@
-- ============================================
-- TEMPLATE-SAAS: Portfolio Schema
-- Version: 1.0.0
-- Module: SAAS-019
-- ============================================
-- Create schema
CREATE SCHEMA IF NOT EXISTS portfolio;
-- Grant permissions
GRANT USAGE ON SCHEMA portfolio TO template_saas_app;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA portfolio TO template_saas_app;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA portfolio TO template_saas_app;
-- Default privileges for future tables
ALTER DEFAULT PRIVILEGES IN SCHEMA portfolio
GRANT ALL PRIVILEGES ON TABLES TO template_saas_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA portfolio
GRANT ALL PRIVILEGES ON SEQUENCES TO template_saas_app;

View File

@ -0,0 +1,41 @@
-- ============================================
-- TEMPLATE-SAAS: Portfolio Enums
-- Version: 1.0.0
-- Module: SAAS-019
-- ============================================
-- Product type enum
CREATE TYPE portfolio.product_type AS ENUM (
'physical',
'digital',
'service',
'subscription',
'bundle'
);
-- Product status enum
CREATE TYPE portfolio.product_status AS ENUM (
'draft',
'active',
'inactive',
'discontinued',
'out_of_stock'
);
-- Price type enum
CREATE TYPE portfolio.price_type AS ENUM (
'one_time',
'recurring',
'usage_based',
'tiered'
);
-- Variant attribute type enum
CREATE TYPE portfolio.attribute_type AS ENUM (
'color',
'size',
'material',
'style',
'capacity',
'custom'
);

View File

@ -0,0 +1,262 @@
-- ============================================
-- TEMPLATE-SAAS: Portfolio Tables
-- Version: 1.0.0
-- Module: SAAS-019
-- ============================================
-- ============================================
-- Categories (hierarchical product categories)
-- ============================================
CREATE TABLE portfolio.categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
-- Hierarchy
parent_id UUID REFERENCES portfolio.categories(id) ON DELETE SET NULL,
-- Category info
name VARCHAR(100) NOT NULL,
slug VARCHAR(120) NOT NULL,
description TEXT,
-- Display
position INT NOT NULL DEFAULT 0,
image_url VARCHAR(500),
color VARCHAR(7) DEFAULT '#3B82F6',
icon VARCHAR(50),
-- Status
is_active BOOLEAN DEFAULT TRUE,
-- SEO
meta_title VARCHAR(200),
meta_description TEXT,
-- Custom fields
custom_fields JSONB DEFAULT '{}'::jsonb,
-- Audit
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
created_by UUID REFERENCES users.users(id) ON DELETE SET NULL,
deleted_at TIMESTAMPTZ,
-- Constraints
CONSTRAINT unique_category_slug UNIQUE (tenant_id, slug),
CONSTRAINT unique_category_position UNIQUE (tenant_id, parent_id, position)
);
COMMENT ON TABLE portfolio.categories IS 'Hierarchical product categories per tenant';
COMMENT ON COLUMN portfolio.categories.parent_id IS 'Parent category for hierarchical structure';
-- ============================================
-- Products (main product catalog)
-- ============================================
CREATE TABLE portfolio.products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
-- Category reference
category_id UUID REFERENCES portfolio.categories(id) ON DELETE SET NULL,
-- Basic info
name VARCHAR(255) NOT NULL,
slug VARCHAR(280) NOT NULL,
sku VARCHAR(100),
barcode VARCHAR(100),
description TEXT,
short_description VARCHAR(500),
-- Product type
product_type portfolio.product_type DEFAULT 'physical' NOT NULL,
status portfolio.product_status DEFAULT 'draft' NOT NULL,
-- Pricing (base price, can be overridden by variants/prices)
base_price DECIMAL(15, 2) DEFAULT 0,
cost_price DECIMAL(15, 2),
compare_at_price DECIMAL(15, 2),
currency VARCHAR(3) DEFAULT 'USD',
-- Inventory
track_inventory BOOLEAN DEFAULT TRUE,
stock_quantity INT DEFAULT 0,
low_stock_threshold INT DEFAULT 5,
allow_backorder BOOLEAN DEFAULT FALSE,
-- Physical properties
weight DECIMAL(10, 3),
weight_unit VARCHAR(10) DEFAULT 'kg',
length DECIMAL(10, 2),
width DECIMAL(10, 2),
height DECIMAL(10, 2),
dimension_unit VARCHAR(10) DEFAULT 'cm',
-- Media
images JSONB DEFAULT '[]'::jsonb,
featured_image_url VARCHAR(500),
-- SEO
meta_title VARCHAR(200),
meta_description TEXT,
tags JSONB DEFAULT '[]'::jsonb,
-- Visibility
is_visible BOOLEAN DEFAULT TRUE,
is_featured BOOLEAN DEFAULT FALSE,
-- Attributes for variants
has_variants BOOLEAN DEFAULT FALSE,
variant_attributes JSONB DEFAULT '[]'::jsonb,
-- Custom fields
custom_fields JSONB DEFAULT '{}'::jsonb,
-- Audit
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
created_by UUID REFERENCES users.users(id) ON DELETE SET NULL,
published_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
-- Constraints
CONSTRAINT unique_product_slug UNIQUE (tenant_id, slug),
CONSTRAINT unique_product_sku UNIQUE (tenant_id, sku),
CONSTRAINT check_prices CHECK (base_price >= 0 AND (cost_price IS NULL OR cost_price >= 0))
);
COMMENT ON TABLE portfolio.products IS 'Product catalog with multi-tenant support';
COMMENT ON COLUMN portfolio.products.images IS 'JSON array of image URLs';
COMMENT ON COLUMN portfolio.products.variant_attributes IS 'JSON array of attribute names used for variants';
-- ============================================
-- Variants (product variants: size, color, etc.)
-- ============================================
CREATE TABLE portfolio.variants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES portfolio.products(id) ON DELETE CASCADE,
-- Variant identification
sku VARCHAR(100),
barcode VARCHAR(100),
name VARCHAR(255),
-- Attributes (e.g., {"color": "red", "size": "L"})
attributes JSONB DEFAULT '{}'::jsonb NOT NULL,
-- Pricing
price DECIMAL(15, 2),
cost_price DECIMAL(15, 2),
compare_at_price DECIMAL(15, 2),
-- Inventory
stock_quantity INT DEFAULT 0,
low_stock_threshold INT,
-- Physical properties (can override product)
weight DECIMAL(10, 3),
-- Media
image_url VARCHAR(500),
-- Status
is_active BOOLEAN DEFAULT TRUE,
position INT DEFAULT 0,
-- Audit
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
deleted_at TIMESTAMPTZ,
-- Constraints
CONSTRAINT unique_variant_sku UNIQUE (tenant_id, sku),
CONSTRAINT check_variant_prices CHECK (price IS NULL OR price >= 0)
);
COMMENT ON TABLE portfolio.variants IS 'Product variants (size, color, etc.)';
COMMENT ON COLUMN portfolio.variants.attributes IS 'JSON object with attribute key-value pairs';
-- ============================================
-- Prices (multi-currency pricing)
-- ============================================
CREATE TABLE portfolio.prices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
-- Reference (either product or variant)
product_id UUID REFERENCES portfolio.products(id) ON DELETE CASCADE,
variant_id UUID REFERENCES portfolio.variants(id) ON DELETE CASCADE,
-- Pricing
price_type portfolio.price_type DEFAULT 'one_time' NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
amount DECIMAL(15, 2) NOT NULL,
compare_at_amount DECIMAL(15, 2),
-- Recurring pricing (for subscriptions)
billing_period VARCHAR(20),
billing_interval INT,
-- Tiered pricing
min_quantity INT DEFAULT 1,
max_quantity INT,
-- Validity
valid_from TIMESTAMPTZ,
valid_until TIMESTAMPTZ,
-- Priority (for overlapping prices)
priority INT DEFAULT 0,
-- Status
is_active BOOLEAN DEFAULT TRUE,
-- Audit
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
deleted_at TIMESTAMPTZ,
-- Constraints
CONSTRAINT check_price_reference CHECK (
(product_id IS NOT NULL AND variant_id IS NULL) OR
(product_id IS NULL AND variant_id IS NOT NULL)
),
CONSTRAINT check_amount CHECK (amount >= 0),
CONSTRAINT check_quantity_range CHECK (
min_quantity > 0 AND (max_quantity IS NULL OR max_quantity >= min_quantity)
)
);
COMMENT ON TABLE portfolio.prices IS 'Multi-currency pricing for products and variants';
COMMENT ON COLUMN portfolio.prices.billing_period IS 'day, week, month, year for recurring prices';
-- ============================================
-- Triggers for updated_at
-- ============================================
CREATE OR REPLACE FUNCTION portfolio.update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_categories_updated_at
BEFORE UPDATE ON portfolio.categories
FOR EACH ROW
EXECUTE FUNCTION portfolio.update_updated_at();
CREATE TRIGGER trg_products_updated_at
BEFORE UPDATE ON portfolio.products
FOR EACH ROW
EXECUTE FUNCTION portfolio.update_updated_at();
CREATE TRIGGER trg_variants_updated_at
BEFORE UPDATE ON portfolio.variants
FOR EACH ROW
EXECUTE FUNCTION portfolio.update_updated_at();
CREATE TRIGGER trg_prices_updated_at
BEFORE UPDATE ON portfolio.prices
FOR EACH ROW
EXECUTE FUNCTION portfolio.update_updated_at();

View File

@ -0,0 +1,85 @@
-- ============================================
-- TEMPLATE-SAAS: Portfolio Row Level Security
-- Version: 1.0.0
-- Module: SAAS-019
-- ============================================
-- ============================================
-- Enable RLS on all tables
-- ============================================
ALTER TABLE portfolio.categories ENABLE ROW LEVEL SECURITY;
ALTER TABLE portfolio.products ENABLE ROW LEVEL SECURITY;
ALTER TABLE portfolio.variants ENABLE ROW LEVEL SECURITY;
ALTER TABLE portfolio.prices ENABLE ROW LEVEL SECURITY;
-- ============================================
-- Categories Policies
-- ============================================
CREATE POLICY categories_tenant_isolation ON portfolio.categories
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY categories_insert ON portfolio.categories
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY categories_update ON portfolio.categories
FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY categories_delete ON portfolio.categories
FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- ============================================
-- Products Policies
-- ============================================
CREATE POLICY products_tenant_isolation ON portfolio.products
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY products_insert ON portfolio.products
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY products_update ON portfolio.products
FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY products_delete ON portfolio.products
FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- ============================================
-- Variants Policies
-- ============================================
CREATE POLICY variants_tenant_isolation ON portfolio.variants
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY variants_insert ON portfolio.variants
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY variants_update ON portfolio.variants
FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY variants_delete ON portfolio.variants
FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- ============================================
-- Prices Policies
-- ============================================
CREATE POLICY prices_tenant_isolation ON portfolio.prices
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY prices_insert ON portfolio.prices
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY prices_update ON portfolio.prices
FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY prices_delete ON portfolio.prices
FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);

View File

@ -0,0 +1,119 @@
-- ============================================
-- TEMPLATE-SAAS: Portfolio Indexes
-- Version: 1.0.0
-- Module: SAAS-019
-- ============================================
-- ============================================
-- Categories Indexes
-- ============================================
CREATE INDEX idx_categories_tenant ON portfolio.categories(tenant_id);
CREATE INDEX idx_categories_parent ON portfolio.categories(tenant_id, parent_id);
CREATE INDEX idx_categories_slug ON portfolio.categories(tenant_id, slug);
CREATE INDEX idx_categories_position ON portfolio.categories(tenant_id, parent_id, position);
CREATE INDEX idx_categories_active ON portfolio.categories(tenant_id, is_active) WHERE is_active = TRUE;
-- Soft delete filter
CREATE INDEX idx_categories_not_deleted ON portfolio.categories(tenant_id)
WHERE deleted_at IS NULL;
-- ============================================
-- Products Indexes
-- ============================================
-- Primary indexes
CREATE INDEX idx_products_tenant ON portfolio.products(tenant_id);
CREATE INDEX idx_products_category ON portfolio.products(tenant_id, category_id) WHERE category_id IS NOT NULL;
CREATE INDEX idx_products_slug ON portfolio.products(tenant_id, slug);
CREATE INDEX idx_products_sku ON portfolio.products(tenant_id, sku) WHERE sku IS NOT NULL;
CREATE INDEX idx_products_status ON portfolio.products(tenant_id, status);
CREATE INDEX idx_products_type ON portfolio.products(tenant_id, product_type);
-- Search indexes
CREATE INDEX idx_products_name_search ON portfolio.products
USING gin(to_tsvector('simple', name));
-- Filtering indexes
CREATE INDEX idx_products_visible ON portfolio.products(tenant_id, is_visible, status)
WHERE is_visible = TRUE AND deleted_at IS NULL;
CREATE INDEX idx_products_featured ON portfolio.products(tenant_id, is_featured)
WHERE is_featured = TRUE AND deleted_at IS NULL;
-- Price range queries
CREATE INDEX idx_products_price ON portfolio.products(tenant_id, base_price);
-- Inventory queries
CREATE INDEX idx_products_low_stock ON portfolio.products(tenant_id, stock_quantity)
WHERE track_inventory = TRUE AND deleted_at IS NULL;
CREATE INDEX idx_products_out_of_stock ON portfolio.products(tenant_id)
WHERE track_inventory = TRUE AND stock_quantity <= 0 AND deleted_at IS NULL;
-- Soft delete filter
CREATE INDEX idx_products_active ON portfolio.products(tenant_id)
WHERE deleted_at IS NULL;
-- Catalog view (common query pattern)
CREATE INDEX idx_products_catalog ON portfolio.products(tenant_id, category_id, status, base_price)
WHERE deleted_at IS NULL AND is_visible = TRUE;
-- ============================================
-- Variants Indexes
-- ============================================
CREATE INDEX idx_variants_tenant ON portfolio.variants(tenant_id);
CREATE INDEX idx_variants_product ON portfolio.variants(product_id);
CREATE INDEX idx_variants_sku ON portfolio.variants(tenant_id, sku) WHERE sku IS NOT NULL;
CREATE INDEX idx_variants_position ON portfolio.variants(product_id, position);
-- Inventory queries
CREATE INDEX idx_variants_stock ON portfolio.variants(tenant_id, stock_quantity);
-- Active variants
CREATE INDEX idx_variants_active ON portfolio.variants(product_id, is_active)
WHERE is_active = TRUE AND deleted_at IS NULL;
-- Soft delete filter
CREATE INDEX idx_variants_not_deleted ON portfolio.variants(tenant_id)
WHERE deleted_at IS NULL;
-- ============================================
-- Prices Indexes
-- ============================================
CREATE INDEX idx_prices_tenant ON portfolio.prices(tenant_id);
CREATE INDEX idx_prices_product ON portfolio.prices(product_id) WHERE product_id IS NOT NULL;
CREATE INDEX idx_prices_variant ON portfolio.prices(variant_id) WHERE variant_id IS NOT NULL;
CREATE INDEX idx_prices_currency ON portfolio.prices(tenant_id, currency);
CREATE INDEX idx_prices_type ON portfolio.prices(tenant_id, price_type);
-- Active prices
CREATE INDEX idx_prices_active ON portfolio.prices(tenant_id, is_active)
WHERE is_active = TRUE AND deleted_at IS NULL;
-- Time-based price queries
CREATE INDEX idx_prices_validity ON portfolio.prices(tenant_id, valid_from, valid_until)
WHERE is_active = TRUE AND deleted_at IS NULL;
-- Priority for price selection
CREATE INDEX idx_prices_priority ON portfolio.prices(product_id, currency, priority DESC)
WHERE is_active = TRUE AND deleted_at IS NULL;
-- Soft delete filter
CREATE INDEX idx_prices_not_deleted ON portfolio.prices(tenant_id)
WHERE deleted_at IS NULL;
-- ============================================
-- Composite indexes for common queries
-- ============================================
-- Product listing with category
CREATE INDEX idx_products_category_listing ON portfolio.products(tenant_id, category_id, status, position)
WHERE deleted_at IS NULL AND is_visible = TRUE;
-- Product search by tags
CREATE INDEX idx_products_tags ON portfolio.products USING gin(tags);
-- Variant lookup for product
CREATE INDEX idx_variants_product_active ON portfolio.variants(product_id, position)
WHERE is_active = TRUE AND deleted_at IS NULL;
-- Price lookup for product + currency
CREATE INDEX idx_prices_product_currency ON portfolio.prices(product_id, currency, priority DESC, valid_from)
WHERE is_active = TRUE AND deleted_at IS NULL AND product_id IS NOT NULL;