diff --git a/database/functions/customization_options/get_values.sql b/database/functions/customization_options/get_values.sql new file mode 100644 index 0000000..35632fc --- /dev/null +++ b/database/functions/customization_options/get_values.sql @@ -0,0 +1,31 @@ +CREATE OR REPLACE FUNCTION get_customization_option_values( + p_customization_option_id INTEGER, + p_language_iso CHAR(2) DEFAULT 'en' +) RETURNS TABLE ( + value_id INTEGER, + customization_option_id INTEGER, + value_name TEXT, + price_modifier NUMERIC(10, 2), + is_default BOOLEAN, + display_order INTEGER, + is_enabled BOOLEAN +) AS $$ +BEGIN + RETURN QUERY + SELECT + cov.option_value_id as value_id, + cov.customization_option_id, + COALESCE(covt.value_name, cov.value_name) as value_name, + cov.price_modifier, + cov.is_default, + cov.display_order, + cov.is_enabled + FROM customization_option_values cov + LEFT JOIN customization_option_value_translations covt + ON cov.option_value_id = covt.option_value_id + AND covt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + WHERE cov.customization_option_id = p_customization_option_id + AND cov.is_enabled = TRUE + ORDER BY cov.display_order, cov.value_name; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/database/functions/pagination/get_pagination_info.sql b/database/functions/pagination/get_info.sql similarity index 100% rename from database/functions/pagination/get_pagination_info.sql rename to database/functions/pagination/get_info.sql diff --git a/database/functions/product_customizations/add_option.sql b/database/functions/product_customizations/add_option.sql new file mode 100644 index 0000000..68633c5 --- /dev/null +++ b/database/functions/product_customizations/add_option.sql @@ -0,0 +1,64 @@ +CREATE OR REPLACE FUNCTION add_product_customization_option( + p_product_id INTEGER, + p_customization_option_id INTEGER, + p_is_required BOOLEAN DEFAULT FALSE, + p_display_order INTEGER DEFAULT 0 +) RETURNS BOOLEAN AS $$ +DECLARE + v_product_type PRODUCT_TYPE; + v_option_exists BOOLEAN; +BEGIN + -- Validate product exists and is configurable + SELECT product_type INTO v_product_type + FROM products + WHERE product_id = p_product_id + AND is_enabled = TRUE; + + IF v_product_type IS NULL THEN + RAISE EXCEPTION 'Product ID % not found or is disabled', p_product_id + USING ERRCODE = 'C0001'; + END IF; + + IF v_product_type != 'configurable' THEN + RAISE EXCEPTION 'Can only add customization options to configurable products' + USING ERRCODE = 'C0002', + HINT = 'Product type is: ' || v_product_type; + END IF; + + -- Validate customization option exists + SELECT EXISTS ( + SELECT 1 FROM customization_options + WHERE customization_option_id = p_customization_option_id + AND is_enabled = TRUE + ) INTO v_option_exists; + + IF NOT v_option_exists THEN + RAISE EXCEPTION 'Customization option ID % not found or is disabled', p_customization_option_id + USING ERRCODE = 'C0003'; + END IF; + + -- Insert the association + INSERT INTO product_customization_options ( + product_id, + customization_option_id, + is_required, + display_order + ) VALUES ( + p_product_id, + p_customization_option_id, + p_is_required, + p_display_order + ) ON CONFLICT (product_id, customization_option_id) DO UPDATE SET + is_required = EXCLUDED.is_required, + display_order = EXCLUDED.display_order; + + RAISE NOTICE 'Added customization option % to product %', p_customization_option_id, p_product_id; + + RETURN TRUE; + +EXCEPTION + WHEN OTHERS THEN + RAISE NOTICE 'Error adding customization option: % - %', SQLSTATE, SQLERRM; + RAISE; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/database/functions/product_customizations/get_options.sql b/database/functions/product_customizations/get_options.sql new file mode 100644 index 0000000..eac023e --- /dev/null +++ b/database/functions/product_customizations/get_options.sql @@ -0,0 +1,34 @@ +CREATE OR REPLACE FUNCTION get_product_customization_options( + p_product_id INTEGER, + p_language_iso CHAR(2) DEFAULT 'en' +) RETURNS TABLE ( + option_id INTEGER, + product_id INTEGER, + option_name TEXT, + option_type TEXT, + is_required BOOLEAN, + display_order INTEGER, + created_at TIMESTAMPTZ +) AS $$ +BEGIN + RETURN QUERY + SELECT + co.customization_option_id as option_id, + pco.product_id, + COALESCE(cot.option_name, co.option_name) as option_name, + co.option_type, + COALESCE(pco.is_required, co.is_required) as is_required, + COALESCE(pco.display_order, co.display_order) as display_order, + pco.created_at + FROM product_customization_options pco + JOIN customization_options co ON pco.customization_option_id = co.customization_option_id + LEFT JOIN customization_option_translations cot + ON co.customization_option_id = cot.customization_option_id + AND cot.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + JOIN products p ON pco.product_id = p.product_id + WHERE pco.product_id = p_product_id + AND co.is_enabled = TRUE + AND p.is_enabled = TRUE + ORDER BY COALESCE(pco.display_order, co.display_order), co.option_name; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/database/functions/product_variants/get_all.sql b/database/functions/product_variants/get_all.sql new file mode 100644 index 0000000..ff53fd7 --- /dev/null +++ b/database/functions/product_variants/get_all.sql @@ -0,0 +1,35 @@ +CREATE OR REPLACE FUNCTION get_product_variants( + p_product_id INTEGER +) RETURNS TABLE ( + variant_id INTEGER, + product_id INTEGER, + variant_name TEXT, + size TEXT, + quantity INTEGER, + serving_size TEXT, + price_override NUMERIC(10, 2), + final_price NUMERIC(10, 2), + is_default BOOLEAN, + display_order INTEGER +) AS $$ +BEGIN + RETURN QUERY + SELECT + pv.product_variant_id as variant_id, + pv.product_id, + pv.variant_name, + pv.size, + pv.quantity, + pv.serving_size, + pv.price_override, + COALESCE(pv.price_override, p.price) as final_price, + pv.is_default, + pv.display_order + FROM product_variants pv + JOIN products p ON pv.product_id = p.product_id + WHERE pv.product_id = p_product_id + AND pv.is_test = FALSE + AND p.is_enabled = TRUE + ORDER BY pv.display_order, pv.product_variant_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/database/functions/products/add_new.sql b/database/functions/products/add_new.sql new file mode 100644 index 0000000..b690ba0 --- /dev/null +++ b/database/functions/products/add_new.sql @@ -0,0 +1,302 @@ +-- Custom exception types for better error handling +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'raise_product_exception') THEN + NULL; + END IF; +END $$; + +CREATE OR REPLACE FUNCTION add_new_product( + -- Required product information + p_product_name TEXT, + p_product_description TEXT, + p_product_type PRODUCT_TYPE, + p_category_id INTEGER, + + -- Pricing (one must be provided based on product type) + p_price NUMERIC(10, 2) DEFAULT NULL, + p_base_price NUMERIC(10, 2) DEFAULT NULL, + + -- Optional product details + p_image_url TEXT DEFAULT NULL, + p_preparation_time_hours INTEGER DEFAULT 48, + p_min_order_hours INTEGER DEFAULT 48, + p_serving_info TEXT DEFAULT NULL, + p_is_customizable BOOLEAN DEFAULT FALSE, + + -- Optional arrays for related data + p_tag_ids INTEGER [] DEFAULT NULL, + p_attributes JSONB DEFAULT NULL, -- Format: [{"name": "allergen", "value": "gluten", "color": "#FF6B6B"}] + p_translations JSONB DEFAULT NULL, -- Format: [{"language_iso": "en", "name": "...", "description": "..."}] + + -- Audit information + p_created_by TEXT DEFAULT 'system' +) RETURNS INTEGER AS $$ +DECLARE + v_product_id INTEGER; + v_category_exists BOOLEAN; + v_tag_record RECORD; + v_attr_record RECORD; + v_trans_record RECORD; + v_language_id INTEGER; +BEGIN + -- ============================================================================= + -- INPUT VALIDATION AND SECURITY CHECKS + -- ============================================================================= + + -- Validate required fields + IF p_product_name IS NULL OR TRIM(p_product_name) = '' THEN + RAISE EXCEPTION 'Product name cannot be empty' + USING ERRCODE = 'P0001', + HINT = 'Please provide a valid product name'; + END IF; + + IF p_product_description IS NULL OR TRIM(p_product_description) = '' THEN + RAISE EXCEPTION 'Product description cannot be empty' + USING ERRCODE = 'P0002', + HINT = 'Please provide a product description'; + END IF; + + -- Validate product name length and format + IF LENGTH(TRIM(p_product_name)) < 3 THEN + RAISE EXCEPTION 'Product name must be at least 3 characters long' + USING ERRCODE = 'P0003'; + END IF; + + IF LENGTH(TRIM(p_product_name)) > 200 THEN + RAISE EXCEPTION 'Product name cannot exceed 200 characters' + USING ERRCODE = 'P0004'; + END IF; + + -- Sanitize and validate product name (prevent XSS) + IF p_product_name ~ '[<>''"]' THEN + RAISE EXCEPTION 'Product name contains invalid characters' + USING ERRCODE = 'P0005', + HINT = 'Product name cannot contain <, >, quotes, or other special characters'; + END IF; + + -- Validate description length + IF LENGTH(p_product_description) > 2000 THEN + RAISE EXCEPTION 'Product description cannot exceed 2000 characters' + USING ERRCODE = 'P0006'; + END IF; + + -- Validate pricing based on product type + IF p_product_type = 'configurable' THEN + IF p_base_price IS NULL OR p_base_price < 0 THEN + RAISE EXCEPTION 'Configurable products require a valid base_price >= 0' + USING ERRCODE = 'P0007', + HINT = 'Set base_price for configurable products'; + END IF; + IF p_price IS NOT NULL THEN + RAISE WARNING 'Price field ignored for configurable products, using base_price'; + END IF; + ELSE + IF p_price IS NULL OR p_price < 0 THEN + RAISE EXCEPTION 'Standard and variant-based products require a valid price >= 0' + USING ERRCODE = 'P0008', + HINT = 'Set price for non-configurable products'; + END IF; + IF p_base_price IS NOT NULL THEN + RAISE WARNING 'Base price field ignored for non-configurable products'; + END IF; + END IF; + + -- Validate time constraints + IF p_preparation_time_hours < 0 OR p_preparation_time_hours > 8760 THEN -- Max 1 year + RAISE EXCEPTION 'Preparation time must be between 0 and 8760 hours' + USING ERRCODE = 'P0009'; + END IF; + + IF p_min_order_hours < 0 OR p_min_order_hours > 8760 THEN + RAISE EXCEPTION 'Minimum order time must be between 0 and 8760 hours' + USING ERRCODE = 'P0010'; + END IF; + + -- Validate category exists and is enabled + SELECT EXISTS ( + SELECT 1 FROM product_categories + WHERE category_id = p_category_id + AND is_enabled = TRUE + ) INTO v_category_exists; + + IF NOT v_category_exists THEN + RAISE EXCEPTION 'Invalid or disabled category ID: %', p_category_id + USING ERRCODE = 'P0011', + HINT = 'Please select a valid, enabled category'; + END IF; + + -- Validate image URL format if provided + IF p_image_url IS NOT NULL AND p_image_url !~ '^https?://[^\s]+\.(jpg|jpeg|png|webp)(\?[^\s]*)?$' THEN + RAISE EXCEPTION 'Invalid image URL format' + USING ERRCODE = 'P0012', + HINT = 'Image URL must be a valid HTTP/HTTPS URL pointing to jpg, jpeg, png, or webp file'; + END IF; + + -- Check for duplicate product names (case-insensitive) + IF EXISTS ( + SELECT 1 FROM products + WHERE LOWER(product_name) = LOWER(TRIM(p_product_name)) + AND is_enabled = TRUE + ) THEN + RAISE EXCEPTION 'A product with this name already exists' + USING ERRCODE = 'P0013', + HINT = 'Please choose a different product name'; + END IF; + + -- Validate tag IDs if provided + IF p_tag_ids IS NOT NULL THEN + FOR i IN 1..array_length(p_tag_ids, 1) LOOP + IF NOT EXISTS ( + SELECT 1 FROM product_tags + WHERE product_tag_id = p_tag_ids[i] + AND is_enabled = TRUE + ) THEN + RAISE EXCEPTION 'Invalid or disabled tag ID: %', p_tag_ids[i] + USING ERRCODE = 'P0014', + HINT = 'All tag IDs must reference existing, enabled tags'; + END IF; + END LOOP; + END IF; + + -- ============================================================================= + -- INSERT MAIN PRODUCT RECORD + -- ============================================================================= + + INSERT INTO products ( + category_id, + product_name, + product_description, + product_type, + price, + base_price, + image_url, + preparation_time_hours, + min_order_hours, + serving_info, + is_customizable + ) VALUES ( + p_category_id, + TRIM(p_product_name), + TRIM(p_product_description), + p_product_type, + CASE WHEN p_product_type != 'configurable' THEN p_price ELSE NULL END, + CASE WHEN p_product_type = 'configurable' THEN p_base_price ELSE NULL END, + p_image_url, + p_preparation_time_hours, + p_min_order_hours, + p_serving_info, + p_is_customizable + ) RETURNING product_id INTO v_product_id; + + -- ============================================================================= + -- ADD RELATED DATA + -- ============================================================================= + + -- Add tag associations + IF p_tag_ids IS NOT NULL AND array_length(p_tag_ids, 1) > 0 THEN + FOR i IN 1..array_length(p_tag_ids, 1) LOOP + INSERT INTO product_tag_assignments (product_id, product_tag_id) + VALUES (v_product_id, p_tag_ids[i]) + ON CONFLICT (product_id, product_tag_id) DO NOTHING; + END LOOP; + + RAISE NOTICE 'Added % tag associations', array_length(p_tag_ids, 1); + END IF; + + -- Add product attributes + IF p_attributes IS NOT NULL THEN + FOR v_attr_record IN SELECT * FROM jsonb_array_elements(p_attributes) LOOP + -- Validate attribute structure + IF NOT (v_attr_record.value ? 'name' AND v_attr_record.value ? 'value') THEN + RAISE EXCEPTION 'Each attribute must have "name" and "value" fields' + USING ERRCODE = 'P0015'; + END IF; + + -- Validate attribute name and value + IF TRIM(v_attr_record.value->>'name') = '' OR TRIM(v_attr_record.value->>'value') = '' THEN + RAISE EXCEPTION 'Attribute name and value cannot be empty' + USING ERRCODE = 'P0016'; + END IF; + + -- Validate hex color if provided + IF v_attr_record.value ? 'color' THEN + IF NOT (v_attr_record.value->>'color' ~ '^#[0-9A-Fa-f]{6}$') THEN + RAISE EXCEPTION 'Invalid hex color format: %', v_attr_record.value->>'color' + USING ERRCODE = 'P0017', + HINT = 'Color must be in format #RRGGBB'; + END IF; + END IF; + + INSERT INTO product_attributes ( + product_id, + attribute_name, + attribute_value, + attribute_color + ) VALUES ( + v_product_id, + TRIM(v_attr_record.value->>'name'), + TRIM(v_attr_record.value->>'value'), + COALESCE((v_attr_record.value->>'color')::HEX_COLOR, '#32cd32') + ) ON CONFLICT (product_id, attribute_name, attribute_value) DO NOTHING; + END LOOP; + + RAISE NOTICE 'Added product attributes'; + END IF; + + -- Add translations + IF p_translations IS NOT NULL THEN + FOR v_trans_record IN SELECT * FROM jsonb_array_elements(p_translations) LOOP + -- Validate translation structure + IF NOT (v_trans_record.value ? 'language_iso' AND v_trans_record.value ? 'name') THEN + RAISE EXCEPTION 'Each translation must have "language_iso" and "name" fields' + USING ERRCODE = 'P0018'; + END IF; + + -- Get language ID + SELECT language_id INTO v_language_id + FROM languages + WHERE iso_code = v_trans_record.value->>'language_iso'; + + IF v_language_id IS NULL THEN + RAISE EXCEPTION 'Invalid language ISO code: %', v_trans_record.value->>'language_iso' + USING ERRCODE = 'P0019', + HINT = 'Language must exist in languages table'; + END IF; + + -- Validate translation data + IF TRIM(v_trans_record.value->>'name') = '' THEN + RAISE EXCEPTION 'Translation name cannot be empty' + USING ERRCODE = 'P0020'; + END IF; + + INSERT INTO product_translations ( + product_id, + language_id, + product_name, + product_description + ) VALUES ( + v_product_id, + v_language_id, + TRIM(v_trans_record.value->>'name'), + TRIM(COALESCE(v_trans_record.value->>'description', '')) + ) ON CONFLICT (product_id, language_id) DO UPDATE SET + product_name = EXCLUDED.product_name, + product_description = EXCLUDED.product_description; + END LOOP; + + RAISE NOTICE 'Added product translations'; + END IF; + + -- Log successful creation + RAISE NOTICE 'Successfully created product ID % with name "%"', v_product_id, p_product_name; + + RETURN v_product_id; + +EXCEPTION + WHEN OTHERS THEN + -- Log the error for debugging + RAISE NOTICE 'Error creating product "%": % - %', p_product_name, SQLSTATE, SQLERRM; + RAISE; -- Re-raise the exception +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/database/functions/products/add_variant.sql b/database/functions/products/add_variant.sql new file mode 100644 index 0000000..0debd3a --- /dev/null +++ b/database/functions/products/add_variant.sql @@ -0,0 +1,100 @@ +CREATE OR REPLACE FUNCTION add_product_variant( + p_product_id INTEGER, + p_variant_name TEXT, + p_size TEXT DEFAULT NULL, + p_quantity INTEGER DEFAULT NULL, + p_serving_size TEXT DEFAULT NULL, + p_price_override NUMERIC(10, 2) DEFAULT NULL, + p_is_default BOOLEAN DEFAULT FALSE, + p_display_order INTEGER DEFAULT 0 +) RETURNS INTEGER AS $$ +DECLARE + v_variant_id INTEGER; + v_product_type PRODUCT_TYPE; + v_product_exists BOOLEAN; +BEGIN + -- Validate product exists and is variant-based + SELECT product_type INTO v_product_type + FROM products + WHERE product_id = p_product_id + AND is_enabled = TRUE; + + IF v_product_type IS NULL THEN + RAISE EXCEPTION 'Product ID % not found or is disabled', p_product_id + USING ERRCODE = 'V0001'; + END IF; + + IF v_product_type != 'variant_based' THEN + RAISE EXCEPTION 'Can only add variants to variant_based products' + USING ERRCODE = 'V0002', + HINT = 'Product type is: ' || v_product_type; + END IF; + + -- Validate required fields + IF p_variant_name IS NULL OR TRIM(p_variant_name) = '' THEN + RAISE EXCEPTION 'Variant name cannot be empty' + USING ERRCODE = 'V0003'; + END IF; + + -- Validate quantity if provided + IF p_quantity IS NOT NULL AND p_quantity <= 0 THEN + RAISE EXCEPTION 'Quantity must be positive' + USING ERRCODE = 'V0004'; + END IF; + + -- Validate price override if provided + IF p_price_override IS NOT NULL AND p_price_override < 0 THEN + RAISE EXCEPTION 'Price override cannot be negative' + USING ERRCODE = 'V0005'; + END IF; + + -- Check for duplicate variant name within product + IF EXISTS ( + SELECT 1 FROM product_variants + WHERE product_id = p_product_id + AND LOWER(variant_name) = LOWER(TRIM(p_variant_name)) + ) THEN + RAISE EXCEPTION 'A variant with this name already exists for this product' + USING ERRCODE = 'V0006'; + END IF; + + -- If setting as default, unset other defaults + IF p_is_default THEN + UPDATE product_variants + SET is_default = FALSE, updated_at = CURRENT_TIMESTAMP + WHERE product_id = p_product_id AND is_default = TRUE; + END IF; + + -- Insert variant + INSERT INTO product_variants ( + product_id, + variant_name, + size, + quantity, + serving_size, + price_override, + is_default, + display_order, + is_test + ) VALUES ( + p_product_id, + TRIM(p_variant_name), + p_size, + p_quantity, + p_serving_size, + p_price_override, + p_is_default, + p_display_order, + FALSE -- Not a test variant + ) RETURNING product_variant_id INTO v_variant_id; + + RAISE NOTICE 'Added variant ID % to product ID %', v_variant_id, p_product_id; + + RETURN v_variant_id; + +EXCEPTION + WHEN OTHERS THEN + RAISE NOTICE 'Error adding variant to product %: % - %', p_product_id, SQLSTATE, SQLERRM; + RAISE; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/database/functions/products/calculate_product_price.sql b/database/functions/products/calculate_price.sql similarity index 100% rename from database/functions/products/calculate_product_price.sql rename to database/functions/products/calculate_price.sql diff --git a/database/functions/products/get_assigned_tags.sql b/database/functions/products/get_assigned_tags.sql new file mode 100644 index 0000000..49cad3c --- /dev/null +++ b/database/functions/products/get_assigned_tags.sql @@ -0,0 +1,33 @@ +CREATE OR REPLACE FUNCTION get_product_assigned_tags( + p_product_id INTEGER, + p_language_iso CHAR(2) DEFAULT 'en' +) RETURNS TABLE ( + tag_id INTEGER, + product_id INTEGER, + tag_name TEXT, + tag_color TEXT, + tag_description TEXT, + display_order INTEGER, + assigned_at TIMESTAMPTZ +) AS $$ +BEGIN + RETURN QUERY + SELECT + pt.product_tag_id as tag_id, + pta.product_id, + COALESCE(ptt.tag_name, pt.tag_name) as tag_name, + pt.tag_color::TEXT, + COALESCE(ptt.tag_description, pt.tag_description) as tag_description, + pt.display_order, + pta.created_at as assigned_at + FROM product_tag_assignments pta + JOIN product_tags pt ON pta.product_tag_id = pt.product_tag_id + LEFT JOIN product_tag_translations ptt ON pt.product_tag_id = ptt.product_tag_id + AND ptt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + JOIN products p ON pta.product_id = p.product_id + WHERE pta.product_id = p_product_id + AND pt.is_enabled = TRUE + AND p.is_enabled = TRUE + ORDER BY pt.display_order, pt.tag_name; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/database/functions/products/get_attributes.sql b/database/functions/products/get_attributes.sql new file mode 100644 index 0000000..fc8499a --- /dev/null +++ b/database/functions/products/get_attributes.sql @@ -0,0 +1,26 @@ +CREATE OR REPLACE FUNCTION get_product_attributes( + p_product_id INTEGER +) RETURNS TABLE ( + attribute_id INTEGER, + product_id INTEGER, + attribute_name TEXT, + attribute_value TEXT, + attribute_color TEXT, + created_at TIMESTAMPTZ +) AS $$ +BEGIN + RETURN QUERY + SELECT + pa.product_attribute_id as attribute_id, + pa.product_id, + pa.attribute_name, + pa.attribute_value, + pa.attribute_color::TEXT, + pa.created_at + FROM product_attributes pa + JOIN products p ON pa.product_id = p.product_id + WHERE pa.product_id = p_product_id + AND p.is_enabled = TRUE + ORDER BY pa.attribute_name, pa.attribute_value; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/database/functions/products/get_attributes_for_filter.sql b/database/functions/products/get_attributes_for_filter.sql new file mode 100644 index 0000000..5332e68 --- /dev/null +++ b/database/functions/products/get_attributes_for_filter.sql @@ -0,0 +1,23 @@ +CREATE OR REPLACE FUNCTION get_product_attributes_for_filter( + p_category_id INTEGER DEFAULT NULL +) RETURNS TABLE ( + attribute_name TEXT, + attribute_value TEXT, + attribute_color TEXT, + product_count INTEGER +) AS $$ +BEGIN + RETURN QUERY + SELECT + pa.attribute_name, + pa.attribute_value, + pa.attribute_color::TEXT, + COUNT(DISTINCT p.product_id)::INTEGER as product_count + FROM product_attributes pa + JOIN products p ON pa.product_id = p.product_id + WHERE p.is_enabled = TRUE + AND (p_category_id IS NULL OR p.category_id = p_category_id) + GROUP BY pa.attribute_name, pa.attribute_value, pa.attribute_color + ORDER BY pa.attribute_name, pa.attribute_value; +END; +$$ LANGUAGE plpgsql; diff --git a/database/functions/products/get_product_categories.sql b/database/functions/products/get_categories.sql similarity index 100% rename from database/functions/products/get_product_categories.sql rename to database/functions/products/get_categories.sql diff --git a/database/functions/products/get_details.sql b/database/functions/products/get_details.sql new file mode 100644 index 0000000..9cd1ed6 --- /dev/null +++ b/database/functions/products/get_details.sql @@ -0,0 +1,82 @@ +CREATE OR REPLACE FUNCTION get_product_details( + p_product_id INTEGER, + p_language_iso CHAR(2) DEFAULT 'en' +) RETURNS TABLE ( + -- Core product data + product_id INTEGER, + product_name TEXT, + product_description TEXT, + product_type PRODUCT_TYPE, + price NUMERIC(10, 2), + base_price NUMERIC(10, 2), + image_url TEXT, + preparation_time_hours INTEGER, + min_order_hours INTEGER, + serving_info TEXT, + is_customizable BOOLEAN, + created_at TIMESTAMPTZ, + + -- Category data + category_id INTEGER, + category_name TEXT, + category_color TEXT, + category_description TEXT, + + -- Variant data + has_variants BOOLEAN, + default_variant_id INTEGER, + + -- Customization data + has_customization_options BOOLEAN, + + -- Counts for related data + attribute_count INTEGER, + tag_count INTEGER, + image_count INTEGER +) AS $$ +BEGIN + RETURN QUERY + SELECT + p.product_id, + COALESCE(tr.product_name, p.product_name) as product_name, + COALESCE(tr.product_description, p.product_description) as product_description, + p.product_type, + p.price, + p.base_price, + p.image_url, + p.preparation_time_hours, + p.min_order_hours, + p.serving_info, + p.is_customizable, + p.created_at, + + -- Category data + pc.category_id, + COALESCE(ct.category_name, pc.category_name) as category_name, + pc.category_color::TEXT, + COALESCE(ct.category_description, pc.category_description) as category_description, + + -- Variant indicators + (p.product_type = 'variant_based') as has_variants, + (SELECT pv.product_variant_id FROM product_variants pv + WHERE pv.product_id = p.product_id AND pv.is_default = TRUE AND pv.is_test = FALSE + LIMIT 1) as default_variant_id, + + -- Customization indicator + (p.product_type = 'configurable') as has_customization_options, + + -- Related data counts + (SELECT COUNT(*)::INTEGER FROM product_attributes pa WHERE pa.product_id = p.product_id) as attribute_count, + (SELECT COUNT(*)::INTEGER FROM product_tag_assignments pta + JOIN product_tags pt ON pta.product_tag_id = pt.product_tag_id + WHERE pta.product_id = p.product_id AND pt.is_enabled = TRUE) as tag_count, + (SELECT COUNT(*)::INTEGER FROM product_images pi WHERE pi.product_id = p.product_id) as image_count + + FROM products p + LEFT JOIN product_categories pc ON p.category_id = pc.category_id + LEFT JOIN category_translations ct ON pc.category_id = ct.category_id + AND ct.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + LEFT JOIN LATERAL get_product_translation(p.product_id, p_language_iso) tr ON true + WHERE p.product_id = p_product_id AND p.is_enabled = TRUE; +END; +$$ LANGUAGE plpgsql; diff --git a/database/functions/products/get_images.sql b/database/functions/products/get_images.sql new file mode 100644 index 0000000..207e828 --- /dev/null +++ b/database/functions/products/get_images.sql @@ -0,0 +1,28 @@ +CREATE OR REPLACE FUNCTION get_product_images( + p_product_id INTEGER, + p_variant_id INTEGER DEFAULT NULL +) RETURNS TABLE ( + image_id INTEGER, + product_id INTEGER, + variant_id INTEGER, + image_url TEXT, + is_primary BOOLEAN, + created_at TIMESTAMPTZ +) AS $$ +BEGIN + RETURN QUERY + SELECT + pi.product_image_id as image_id, + pi.product_id, + pi.variant_id, + pi.image_url, + pi.is_primary, + pi.created_at + FROM product_images pi + JOIN products p ON pi.product_id = p.product_id + WHERE pi.product_id = p_product_id + AND (p_variant_id IS NULL OR pi.variant_id = p_variant_id OR pi.variant_id IS NULL) + AND p.is_enabled = TRUE + ORDER BY pi.is_primary DESC, pi.created_at; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/database/functions/products/get_paginated.sql b/database/functions/products/get_paginated.sql new file mode 100644 index 0000000..d688de0 --- /dev/null +++ b/database/functions/products/get_paginated.sql @@ -0,0 +1,110 @@ +CREATE OR REPLACE FUNCTION get_products_paginated( + p_page INTEGER DEFAULT 1, + p_size INTEGER DEFAULT 12, + p_language_iso CHAR(2) DEFAULT 'en', + p_sort_by TEXT DEFAULT 'created_at', + p_sort_order TEXT DEFAULT 'DESC', + p_category_filter INTEGER DEFAULT NULL, + p_tag_filter INTEGER [] DEFAULT NULL, + p_attribute_filter JSONB DEFAULT NULL +) RETURNS TABLE ( + -- Core product data + product_id INTEGER, + product_name TEXT, + product_description TEXT, + product_type PRODUCT_TYPE, + price NUMERIC(10, 2), + base_price NUMERIC(10, 2), + image_url TEXT, + preparation_time_hours INTEGER, + min_order_hours INTEGER, + serving_info TEXT, + is_customizable BOOLEAN, + created_at TIMESTAMPTZ, + + -- Category data + category_id INTEGER, + category_name TEXT, + category_color TEXT, + category_description TEXT, + + -- Variant indicators + has_variants BOOLEAN, + default_variant_id INTEGER, + variant_count INTEGER, + + -- Related data counts + attribute_count INTEGER, + tag_count INTEGER, + image_count INTEGER, + + -- Pagination info + pagination PAGINATION_INFO +) AS $$ +DECLARE + v_total_count BIGINT; + v_offset INTEGER := (p_page - 1) * p_size; +BEGIN + -- Get total count for pagination + SELECT COUNT(*) INTO v_total_count + FROM products p + WHERE p.is_enabled = TRUE + AND (p_category_filter IS NULL OR p.category_id = p_category_filter); + + RETURN QUERY + SELECT + p.product_id, + COALESCE(tr.product_name, p.product_name) as product_name, + COALESCE(tr.product_description, p.product_description) as product_description, + p.product_type, + p.price, + p.base_price, + p.image_url, + p.preparation_time_hours, + p.min_order_hours, + p.serving_info, + p.is_customizable, + p.created_at, + + -- Category data + pc.category_id, + COALESCE(ct.category_name, pc.category_name) as category_name, + pc.category_color::TEXT, + COALESCE(ct.category_description, pc.category_description) as category_description, + + -- Variant indicators + (p.product_type = 'variant_based') as has_variants, + (SELECT product_variant_id FROM product_variants pv + WHERE pv.product_id = p.product_id AND pv.is_default = TRUE AND pv.is_test = FALSE + LIMIT 1) as default_variant_id, + (SELECT COUNT(*)::INTEGER FROM product_variants pv + WHERE pv.product_id = p.product_id AND pv.is_test = FALSE) as variant_count, + + -- Related data counts + (SELECT COUNT(*)::INTEGER FROM product_attributes pa WHERE pa.product_id = p.product_id) as attribute_count, + (SELECT COUNT(*)::INTEGER FROM product_tag_assignments pta + JOIN product_tags pt ON pta.product_tag_id = pt.product_tag_id + WHERE pta.product_id = p.product_id AND pt.is_enabled = TRUE) as tag_count, + (SELECT COUNT(*)::INTEGER FROM product_images pi WHERE pi.product_id = p.product_id) as image_count, + + -- Pagination info + get_pagination_info(p_page, p_size, v_total_count) as pagination + + FROM products p + LEFT JOIN product_categories pc ON p.category_id = pc.category_id + LEFT JOIN category_translations ct ON pc.category_id = ct.category_id + AND ct.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + LEFT JOIN LATERAL get_product_translation(p.product_id, p_language_iso) tr ON true + WHERE p.is_enabled = TRUE + AND (p_category_filter IS NULL OR p.category_id = p_category_filter) + ORDER BY + CASE WHEN p_sort_by = 'created_at' AND p_sort_order = 'DESC' THEN p.created_at END DESC, + CASE WHEN p_sort_by = 'created_at' AND p_sort_order = 'ASC' THEN p.created_at END ASC, + CASE WHEN p_sort_by = 'price' AND p_sort_order = 'DESC' THEN COALESCE(p.price, p.base_price) END DESC, + CASE WHEN p_sort_by = 'price' AND p_sort_order = 'ASC' THEN COALESCE(p.price, p.base_price) END ASC, + CASE WHEN p_sort_by = 'name' AND p_sort_order = 'DESC' THEN COALESCE(tr.product_name, p.product_name) END DESC, + CASE WHEN p_sort_by = 'name' AND p_sort_order = 'ASC' THEN COALESCE(tr.product_name, p.product_name) END ASC, + p.product_id + LIMIT p_size OFFSET v_offset; +END; +$$ LANGUAGE plpgsql; diff --git a/database/functions/products/get_product_attributes_for_filter.sql b/database/functions/products/get_product_attributes_for_filter.sql deleted file mode 100644 index dca1718..0000000 --- a/database/functions/products/get_product_attributes_for_filter.sql +++ /dev/null @@ -1,33 +0,0 @@ -CREATE OR REPLACE FUNCTION get_product_attributes_for_filter( - p_category_id INTEGER DEFAULT NULL -) RETURNS TABLE ( - attribute_name TEXT, - attribute_values JSONB -) AS $$ -BEGIN - RETURN QUERY - SELECT - pa.attribute_name, - jsonb_agg( - DISTINCT jsonb_build_object( - 'value', pa.attribute_value, - 'color', pa.attribute_color::TEXT, - 'count', ( - SELECT COUNT(DISTINCT p2.product_id) - FROM product_attributes pa2 - JOIN products p2 ON pa2.product_id = p2.product_id - WHERE pa2.attribute_name = pa.attribute_name - AND pa2.attribute_value = pa.attribute_value - AND p2.is_enabled = TRUE - AND (p_category_id IS NULL OR p2.category_id = p_category_id) - ) - ) - ) as attribute_values - FROM product_attributes pa - JOIN products p ON pa.product_id = p.product_id - WHERE p.is_enabled = TRUE - AND (p_category_id IS NULL OR p.category_id = p_category_id) - GROUP BY pa.attribute_name - ORDER BY pa.attribute_name; -END; -$$ LANGUAGE plpgsql; diff --git a/database/functions/products/get_product_details.sql b/database/functions/products/get_product_details.sql deleted file mode 100644 index df36676..0000000 --- a/database/functions/products/get_product_details.sql +++ /dev/null @@ -1,158 +0,0 @@ -CREATE OR REPLACE FUNCTION get_product_details( - p_product_id INTEGER, - p_language_iso CHAR(2) DEFAULT 'en' -) RETURNS TABLE ( - -- Product data - product_id INTEGER, - product_name TEXT, - product_description TEXT, - product_type PRODUCT_TYPE, - price NUMERIC(10, 2), - base_price NUMERIC(10, 2), - image_url TEXT, - preparation_time_hours INTEGER, - min_order_hours INTEGER, - serving_info TEXT, - is_customizable BOOLEAN, - created_at TIMESTAMPTZ, - -- Related data - category JSONB, - variants JSONB, - customization_options JSONB, - attributes JSONB, - tags JSONB, - images JSONB -) AS $$ -BEGIN - RETURN QUERY - SELECT - p.product_id, - COALESCE(tr.product_name, p.product_name) as product_name, - COALESCE(tr.product_description, p.product_description) as product_description, - p.product_type, - p.price, - p.base_price, - p.image_url, - p.preparation_time_hours, - p.min_order_hours, - p.serving_info, - p.is_customizable, - p.created_at, - -- Category - CASE WHEN p.category_id IS NOT NULL THEN - jsonb_build_object( - 'category_id', pc.category_id, - 'name', COALESCE(ct.category_name, pc.category_name), - 'color', pc.category_color::TEXT, - 'description', COALESCE(ct.category_description, pc.category_description) - ) - ELSE NULL END as category, - -- Variants - CASE - WHEN p.product_type = 'variant_based' THEN - (SELECT jsonb_agg( - jsonb_build_object( - 'variant_id', pv.product_variant_id, - 'name', pv.variant_name, - 'quantity', pv.quantity, - 'price', COALESCE(pv.price_override, p.price), - 'serving_size', pv.serving_size, - 'is_default', pv.is_default, - 'size', pv.size - ) ORDER BY pv.display_order - ) FROM product_variants pv - WHERE pv.product_id = p.product_id AND pv.is_test = FALSE) - ELSE NULL - END as variants, - -- Customization options for configurable products - CASE - WHEN p.product_type = 'configurable' THEN - (SELECT jsonb_agg( - jsonb_build_object( - 'option_id', co.customization_option_id, - 'name', COALESCE(cot.option_name, co.option_name), - 'type', co.option_type, - 'is_required', COALESCE(pco.is_required, co.is_required), - 'values', CASE - WHEN co.option_type IN ('single_select', 'multi_select') THEN - (SELECT jsonb_agg( - jsonb_build_object( - 'value_id', cov.option_value_id, - 'name', COALESCE(covt.value_name, cov.value_name), - 'price_modifier', cov.price_modifier, - 'is_default', cov.is_default - ) ORDER BY cov.display_order - ) FROM customization_option_values cov - LEFT JOIN customization_option_value_translations covt - ON cov.option_value_id = covt.option_value_id - AND covt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) - WHERE cov.customization_option_id = co.customization_option_id - AND cov.is_enabled = TRUE) - ELSE NULL - END - ) ORDER BY COALESCE(pco.display_order, co.display_order) - ) FROM product_customization_options pco - JOIN customization_options co ON pco.customization_option_id = co.customization_option_id - LEFT JOIN customization_option_translations cot - ON co.customization_option_id = cot.customization_option_id - AND cot.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) - WHERE pco.product_id = p.product_id AND co.is_enabled = TRUE) - ELSE NULL - END as customization_options, - -- Product attributes (fixed - no nested aggregates) - (SELECT - CASE - WHEN COUNT(*) > 0 THEN - jsonb_object_agg( - attr_grouped.attribute_name, - attr_grouped.values - ) - ELSE NULL - END - FROM ( - SELECT - pa.attribute_name, - jsonb_agg( - jsonb_build_object( - 'value', pa.attribute_value, - 'color', pa.attribute_color::TEXT - ) - ) as values - FROM product_attributes pa - WHERE pa.product_id = p.product_id - GROUP BY pa.attribute_name - ) attr_grouped - ) as attributes, - -- Product tags - (SELECT jsonb_agg( - jsonb_build_object( - 'tag_id', pt.product_tag_id, - 'name', COALESCE(ptt.tag_name, pt.tag_name), - 'color', pt.tag_color::TEXT, - 'description', COALESCE(ptt.tag_description, pt.tag_description) - ) ORDER BY pt.display_order - ) FROM product_tag_assignments pta - JOIN product_tags pt ON pta.product_tag_id = pt.product_tag_id - LEFT JOIN product_tag_translations ptt ON pt.product_tag_id = ptt.product_tag_id - AND ptt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) - WHERE pta.product_id = p.product_id AND pt.is_enabled = TRUE - ) as tags, - -- Product images - (SELECT jsonb_agg( - jsonb_build_object( - 'image_id', pi.product_image_id, - 'url', pi.image_url, - 'is_primary', pi.is_primary, - 'variant_id', pi.variant_id - ) ORDER BY pi.is_primary DESC, pi.created_at - ) FROM product_images pi - WHERE pi.product_id = p.product_id - ) as images - FROM products p - LEFT JOIN product_categories pc ON p.category_id = pc.category_id - LEFT JOIN category_translations ct ON pc.category_id = ct.category_id - AND ct.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) - LEFT JOIN LATERAL get_product_translation(p.product_id, p_language_iso) tr ON true - WHERE p.product_id = p_product_id AND p.is_enabled = TRUE; -END; -$$ LANGUAGE plpgsql; diff --git a/database/functions/products/get_products_paginated.sql b/database/functions/products/get_products_paginated.sql deleted file mode 100644 index 2b21ecd..0000000 --- a/database/functions/products/get_products_paginated.sql +++ /dev/null @@ -1,100 +0,0 @@ -CREATE OR REPLACE FUNCTION get_products_paginated( - p_page INTEGER DEFAULT 1, - p_size INTEGER DEFAULT 12, - p_language_iso CHAR(2) DEFAULT 'en', - p_sort_by TEXT DEFAULT 'created_at', - p_sort_order TEXT DEFAULT 'DESC', - p_category_filter INTEGER DEFAULT NULL, - p_tag_filter INTEGER [] DEFAULT NULL, - p_attribute_filter JSONB DEFAULT NULL -) RETURNS TABLE ( - product_id INTEGER, - product_name TEXT, - product_description TEXT, - product_type PRODUCT_TYPE, - price NUMERIC(10, 2), - base_price NUMERIC(10, 2), - image_url TEXT, - preparation_time_hours INTEGER, - min_order_hours INTEGER, - serving_info TEXT, - is_customizable BOOLEAN, - created_at TIMESTAMPTZ, - category JSONB, - variants JSONB, - attributes JSONB, - tags JSONB, - pagination PAGINATION_INFO -) AS $$ -BEGIN - -- No complex filtering for now - RETURN QUERY - SELECT - p.product_id, - COALESCE(tr.product_name, p.product_name) as product_name, - COALESCE(tr.product_description, p.product_description) as product_description, - p.product_type, - p.price, - p.base_price, - p.image_url, - p.preparation_time_hours, - p.min_order_hours, - p.serving_info, - p.is_customizable, - p.created_at, - -- Category - CASE WHEN p.category_id IS NOT NULL THEN - jsonb_build_object( - 'category_id', pc.category_id, - 'name', COALESCE(ct.category_name, pc.category_name), - 'color', pc.category_color::TEXT, - 'description', COALESCE(ct.category_description, pc.category_description) - ) - ELSE NULL END as category, - -- Variants - (SELECT jsonb_agg( - jsonb_build_object( - 'variant_id', pv.product_variant_id, - 'name', pv.variant_name, - 'quantity', pv.quantity, - 'price', COALESCE(pv.price_override, p.price), - 'serving_size', pv.serving_size, - 'is_default', pv.is_default - ) ORDER BY pv.display_order - ) FROM product_variants pv - WHERE pv.product_id = p.product_id AND pv.is_test = FALSE - ) as variants, - -- Attributes (simplified - no nested aggregates) - (SELECT jsonb_agg( - jsonb_build_object( - 'name', pa.attribute_name, - 'value', pa.attribute_value, - 'color', pa.attribute_color::TEXT - ) - ) FROM product_attributes pa WHERE pa.product_id = p.product_id - ) as attributes, - -- Tags - (SELECT jsonb_agg( - jsonb_build_object( - 'tag_id', pt.product_tag_id, - 'name', COALESCE(ptt.tag_name, pt.tag_name), - 'color', pt.tag_color::TEXT, - 'description', COALESCE(ptt.tag_description, pt.tag_description) - ) ORDER BY pt.display_order - ) FROM product_tag_assignments pta - JOIN product_tags pt ON pta.product_tag_id = pt.product_tag_id - LEFT JOIN product_tag_translations ptt ON pt.product_tag_id = ptt.product_tag_id - AND ptt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) - WHERE pta.product_id = p.product_id AND pt.is_enabled = TRUE - ) as tags, - get_pagination_info(p_page, p_size, (SELECT COUNT(*) FROM products WHERE is_enabled = TRUE)::BIGINT) as pagination - FROM products p - LEFT JOIN product_categories pc ON p.category_id = pc.category_id - LEFT JOIN category_translations ct ON pc.category_id = ct.category_id - AND ct.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) - LEFT JOIN LATERAL get_product_translation(p.product_id, p_language_iso) tr ON true - WHERE p.is_enabled = TRUE - ORDER BY p.created_at DESC - LIMIT p_size OFFSET (p_page - 1) * p_size; -END; -$$ LANGUAGE plpgsql; diff --git a/database/functions/products/get_product_tags.sql b/database/functions/products/get_tags.sql similarity index 100% rename from database/functions/products/get_product_tags.sql rename to database/functions/products/get_tags.sql diff --git a/database/functions/products/search.sql b/database/functions/products/search.sql new file mode 100644 index 0000000..72f77da --- /dev/null +++ b/database/functions/products/search.sql @@ -0,0 +1,130 @@ +CREATE OR REPLACE FUNCTION search_products( + p_search_term TEXT, + p_page INTEGER DEFAULT 1, + p_size INTEGER DEFAULT 24, + p_language_iso CHAR(2) DEFAULT 'en', + p_category_filter INTEGER DEFAULT NULL, + p_tag_filter INTEGER [] DEFAULT NULL, + p_attribute_filter JSONB DEFAULT NULL +) RETURNS TABLE ( + -- Core product data + product_id INTEGER, + product_name TEXT, + product_description TEXT, + product_type PRODUCT_TYPE, + price NUMERIC(10, 2), + base_price NUMERIC(10, 2), + image_url TEXT, + preparation_time_hours INTEGER, + min_order_hours INTEGER, + serving_info TEXT, + is_customizable BOOLEAN, + created_at TIMESTAMPTZ, + + -- Category data + category_id INTEGER, + category_name TEXT, + category_color TEXT, + + -- Search relevance + search_rank REAL, + + -- Related data counts + attribute_count INTEGER, + tag_count INTEGER, + + -- Pagination info + pagination PAGINATION_INFO +) AS $$ +DECLARE + v_total_count BIGINT; + v_offset INTEGER := (p_page - 1) * p_size; + v_query tsquery; +BEGIN + -- Prepare search query + v_query := websearch_to_tsquery('english', p_search_term); + + -- Get total count for pagination + WITH search_results AS ( + SELECT DISTINCT p.product_id + FROM products p + LEFT JOIN product_translations pt ON p.product_id = pt.product_id + AND pt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + WHERE p.is_enabled = TRUE + AND ( + to_tsvector('english', p.product_name || ' ' || COALESCE(p.product_description, '')) @@ v_query + OR to_tsvector('english', COALESCE(pt.product_name, '') || ' ' || COALESCE(pt.product_description, '')) @@ v_query + ) + AND (p_category_filter IS NULL OR p.category_id = p_category_filter) + AND (p_tag_filter IS NULL OR EXISTS ( + SELECT 1 FROM product_tag_assignments pta + WHERE pta.product_id = p.product_id + AND pta.product_tag_id = ANY(p_tag_filter) + )) + ) + SELECT COUNT(*) INTO v_total_count FROM search_results; + + RETURN QUERY + WITH search_results AS ( + SELECT + p.product_id, + ts_rank_cd( + to_tsvector('english', p.product_name || ' ' || COALESCE(p.product_description, '')), + v_query + ) as rank + FROM products p + LEFT JOIN product_translations pt ON p.product_id = pt.product_id + AND pt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + WHERE p.is_enabled = TRUE + AND ( + to_tsvector('english', p.product_name || ' ' || COALESCE(p.product_description, '')) @@ v_query + OR to_tsvector('english', COALESCE(pt.product_name, '') || ' ' || COALESCE(pt.product_description, '')) @@ v_query + ) + AND (p_category_filter IS NULL OR p.category_id = p_category_filter) + AND (p_tag_filter IS NULL OR EXISTS ( + SELECT 1 FROM product_tag_assignments pta + WHERE pta.product_id = p.product_id + AND pta.product_tag_id = ANY(p_tag_filter) + )) + ) + SELECT + p.product_id, + COALESCE(tr.product_name, p.product_name) as product_name, + COALESCE(tr.product_description, p.product_description) as product_description, + p.product_type, + p.price, + p.base_price, + p.image_url, + p.preparation_time_hours, + p.min_order_hours, + p.serving_info, + p.is_customizable, + p.created_at, + + -- Category data + pc.category_id, + COALESCE(ct.category_name, pc.category_name) as category_name, + pc.category_color::TEXT, + + -- Search relevance + sr.rank as search_rank, + + -- Related data counts + (SELECT COUNT(*)::INTEGER FROM product_attributes pa WHERE pa.product_id = p.product_id) as attribute_count, + (SELECT COUNT(*)::INTEGER FROM product_tag_assignments pta + JOIN product_tags pt ON pta.product_tag_id = pt.product_tag_id + WHERE pta.product_id = p.product_id AND pt.is_enabled = TRUE) as tag_count, + + -- Pagination info + get_pagination_info(p_page, p_size, v_total_count) as pagination + + FROM search_results sr + JOIN products p ON sr.product_id = p.product_id + LEFT JOIN product_categories pc ON p.category_id = pc.category_id + LEFT JOIN category_translations ct ON pc.category_id = ct.category_id + AND ct.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) + LEFT JOIN LATERAL get_product_translation(p.product_id, p_language_iso) tr ON true + ORDER BY sr.rank DESC, p.created_at DESC + LIMIT p_size OFFSET v_offset; +END; +$$ LANGUAGE plpgsql; diff --git a/database/functions/products/search_products.sql b/database/functions/products/search_products.sql deleted file mode 100644 index 47e8746..0000000 --- a/database/functions/products/search_products.sql +++ /dev/null @@ -1,102 +0,0 @@ -CREATE OR REPLACE FUNCTION search_products( - p_search_term TEXT, - p_page INTEGER DEFAULT 1, - p_size INTEGER DEFAULT 24, - p_language_iso CHAR(2) DEFAULT 'en', - p_category_filter INTEGER DEFAULT NULL, - p_tag_filter INTEGER [] DEFAULT NULL, - p_attribute_filter JSONB DEFAULT NULL -) RETURNS TABLE ( - product_id INTEGER, - product_name TEXT, - product_description TEXT, - product_type PRODUCT_TYPE, - price NUMERIC(10, 2), - base_price NUMERIC(10, 2), - image_url TEXT, - category JSONB, - attributes JSONB, - tags JSONB, - rank REAL, - pagination PAGINATION_INFO -) AS $$ -BEGIN - RETURN QUERY - WITH search_query AS ( - SELECT websearch_to_tsquery('english', p_search_term) as query - ), - filtered_products AS ( - SELECT DISTINCT p.product_id - FROM products p - LEFT JOIN product_translations pt ON p.product_id = pt.product_id - AND pt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) - CROSS JOIN search_query sq - WHERE p.is_enabled = TRUE - AND ( - to_tsvector('english', p.product_name || ' ' || COALESCE(p.product_description, '')) @@ sq.query - OR to_tsvector('english', COALESCE(pt.product_name, '') || ' ' || COALESCE(pt.product_description, '')) @@ sq.query - ) - AND (p_category_filter IS NULL OR p.category_id = p_category_filter) - AND (p_tag_filter IS NULL OR EXISTS ( - SELECT 1 FROM product_tag_assignments pta - WHERE pta.product_id = p.product_id - AND pta.product_tag_id = ANY(p_tag_filter) - )) - ), - paginated_results AS ( - SELECT fp.product_id, - ROW_NUMBER() OVER (ORDER BY p.created_at DESC) as rn - FROM filtered_products fp - JOIN products p ON fp.product_id = p.product_id - LIMIT p_size OFFSET (p_page - 1) * p_size - ) - SELECT - p.product_id, - COALESCE(tr.product_name, p.product_name) as product_name, - COALESCE(tr.product_description, p.product_description) as product_description, - p.product_type, - p.price, - p.base_price, - p.image_url, - -- Category - CASE WHEN p.category_id IS NOT NULL THEN - jsonb_build_object( - 'category_id', pc.category_id, - 'name', COALESCE(ct.category_name, pc.category_name), - 'color', pc.category_color::TEXT - ) - ELSE NULL END as category, - -- Attributes (simplified approach - no nested aggregates) - (SELECT jsonb_agg( - jsonb_build_object( - 'name', pa.attribute_name, - 'value', pa.attribute_value, - 'color', pa.attribute_color::TEXT - ) - ) FROM product_attributes pa WHERE pa.product_id = p.product_id - ) as attributes, - -- Tags - (SELECT jsonb_agg( - jsonb_build_object( - 'tag_id', pt2.product_tag_id, - 'name', COALESCE(ptt.tag_name, pt2.tag_name), - 'color', pt2.tag_color::TEXT - ) - ) FROM product_tag_assignments pta - JOIN product_tags pt2 ON pta.product_tag_id = pt2.product_tag_id - LEFT JOIN product_tag_translations ptt ON pt2.product_tag_id = ptt.product_tag_id - AND ptt.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) - WHERE pta.product_id = p.product_id AND pt2.is_enabled = TRUE - ) as tags, - -- Search ranking - 0.5::REAL as rank, -- Simplified ranking - get_pagination_info(p_page, p_size, (SELECT COUNT(*) FROM filtered_products)::BIGINT) as pagination - FROM paginated_results pr - JOIN products p ON pr.product_id = p.product_id - LEFT JOIN product_categories pc ON p.category_id = pc.category_id - LEFT JOIN category_translations ct ON pc.category_id = ct.category_id - AND ct.language_id = (SELECT language_id FROM languages WHERE iso_code = p_language_iso) - LEFT JOIN LATERAL get_product_translation(p.product_id, p_language_iso) tr ON true - ORDER BY pr.rn; -END; -$$ LANGUAGE plpgsql; diff --git a/database/functions/products/update_status.sql b/database/functions/products/update_status.sql new file mode 100644 index 0000000..dd96c1f --- /dev/null +++ b/database/functions/products/update_status.sql @@ -0,0 +1,44 @@ +CREATE OR REPLACE FUNCTION update_product_status( + p_product_id INTEGER, + p_is_enabled BOOLEAN, + p_updated_by TEXT DEFAULT 'system' +) RETURNS BOOLEAN AS $$ +DECLARE + v_current_status BOOLEAN; + v_product_name TEXT; +BEGIN + -- Check if product exists and get current status + SELECT is_enabled, product_name + INTO v_current_status, v_product_name + FROM products + WHERE product_id = p_product_id; + + IF v_current_status IS NULL THEN + RAISE EXCEPTION 'Product with ID % not found', p_product_id + USING ERRCODE = 'P0021'; + END IF; + + -- Check if status change is needed + IF v_current_status = p_is_enabled THEN + RAISE NOTICE 'Product "%" already has status: %', v_product_name, + CASE WHEN p_is_enabled THEN 'enabled' ELSE 'disabled' END; + RETURN TRUE; + END IF; + + -- Update product status + UPDATE products + SET is_enabled = p_is_enabled, + updated_at = CURRENT_TIMESTAMP + WHERE product_id = p_product_id; + + RAISE NOTICE 'Product "%" status changed to: %', v_product_name, + CASE WHEN p_is_enabled THEN 'enabled' ELSE 'disabled' END; + + RETURN TRUE; + +EXCEPTION + WHEN OTHERS THEN + RAISE NOTICE 'Error updating product status: % - %', SQLSTATE, SQLERRM; + RAISE; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/database/functions/translations/get_product_translation.sql b/database/functions/translations/get_translation.sql similarity index 100% rename from database/functions/translations/get_product_translation.sql rename to database/functions/translations/get_translation.sql diff --git a/database/schema.sql b/database/schema.sql index f69b31a..e538306 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -14,15 +14,6 @@ CREATE TYPE pagination_info AS ( has_previous BOOLEAN ); -CREATE TYPE pagination_info AS ( - current_page INTEGER, - page_size INTEGER, -- Default is 12 for listings, 24 for search, 36 for admin - total_items BIGINT, - total_pages INTEGER, - has_next BOOLEAN, - has_previous BOOLEAN -); - -- Languages (i18n) - followed by translation tables later in the schema CREATE TABLE languages (