diff --git a/ddl/schemas/portfolio/00-schema.sql b/ddl/schemas/portfolio/00-schema.sql new file mode 100644 index 0000000..ef4cd07 --- /dev/null +++ b/ddl/schemas/portfolio/00-schema.sql @@ -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; diff --git a/ddl/schemas/portfolio/01-enums.sql b/ddl/schemas/portfolio/01-enums.sql new file mode 100644 index 0000000..347278c --- /dev/null +++ b/ddl/schemas/portfolio/01-enums.sql @@ -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' +); diff --git a/ddl/schemas/portfolio/02-tables.sql b/ddl/schemas/portfolio/02-tables.sql new file mode 100644 index 0000000..cec9666 --- /dev/null +++ b/ddl/schemas/portfolio/02-tables.sql @@ -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(); diff --git a/ddl/schemas/portfolio/04-rls.sql b/ddl/schemas/portfolio/04-rls.sql new file mode 100644 index 0000000..cfe7a30 --- /dev/null +++ b/ddl/schemas/portfolio/04-rls.sql @@ -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); diff --git a/ddl/schemas/portfolio/05-indexes.sql b/ddl/schemas/portfolio/05-indexes.sql new file mode 100644 index 0000000..04e58cd --- /dev/null +++ b/ddl/schemas/portfolio/05-indexes.sql @@ -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;