-- =============================================================================
-- production_structure_consolidated.sql — full DB structure for stacie_Aggie_v1.0
--
-- Single-file consolidation of every schema/procedure/view migration this repo
-- ships. Generated by concatenating, in dependency order, the same files
-- abos-api/db/apply-migrations.sh already applies individually — this is not a
-- separate hand-maintained copy, it's a mechanical join of the same source-of-
-- truth files, so keep making real edits in the individual files and regenerate
-- this one, not the other way around.
--
-- Covers LarCare's website/CMS data too (dim_web_pages, dim_web_forms,
-- dim_web_products, etc.) — LarCare has no separate database; its content
-- lives in this same stacie_Aggie_v1.0 database via the WEB module, and
-- LarCare/frontend calls this backend's /api/public/web/* endpoints directly.
-- The standalone LarCare/backend app + its own 'larcare' database are legacy
-- and are not part of this file (not deployed — deploy-server1.yml's LarCare
-- backend steps are commented out).
--
-- Safe to re-run: every section keeps its own idempotent guards (CREATE ... IF
-- NOT EXISTS, information_schema-guarded ALTERs, DROP+CREATE PROCEDURE/VIEW).
--
-- Usage (as the app DB user, e.g. from CI or by hand):
--     mysql -u stacie_aggie -p stacie_Aggie_v1.0 < production_structure_consolidated.sql
-- =============================================================================

-- ----- app_settings.sql -----
USE `stacie_Aggie_v1.0`;

-- Global application branding/settings — single row, no tenant_id (unlike most
-- tables in this schema) since these values apply platform-wide, not per-tenant.
CREATE TABLE IF NOT EXISTS app_settings (
    id INT AUTO_INCREMENT PRIMARY KEY,
    app_title VARCHAR(255) NOT NULL,
    app_subtitle VARCHAR(255),
    document_title VARCHAR(255),
    copyright_holder VARCHAR(255),
    login_welcome_message VARCHAR(500),
    login_sign_in_label VARCHAR(100),
    logo_url VARCHAR(500),
    favicon_url VARCHAR(500),
    primary_color VARCHAR(20),
    login_background_color VARCHAR(20),
    footer_text VARCHAR(500),
    support_email VARCHAR(255),
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
);

INSERT INTO app_settings (
    app_title, app_subtitle, document_title, copyright_holder,
    login_welcome_message, login_sign_in_label, logo_url, favicon_url,
    primary_color, login_background_color, footer_text, support_email
)
SELECT
    'ABOS V1', 'Agentic ERP', 'ABOS — TechMAC Agentic ERP', 'NSP Foundation',
    'Sign in to your ERP workspace', 'Sign In', '', '',
    '#1a237e', '#f0f4ff', '© 2026 TechMAC. All rights reserved.', 'dev@techmac.co.uk'
WHERE NOT EXISTS (SELECT 1 FROM app_settings);

-- ----- dim_physical_assets.sql -----
USE `stacie_Aggie_v1.0`;

CREATE TABLE IF NOT EXISTS dim_physical_assets (
    id INT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    asset_tag VARCHAR(100) NOT NULL,
    name VARCHAR(255) NOT NULL,
    category VARCHAR(100) NULL,
    status VARCHAR(50) NOT NULL DEFAULT 'Available',
    purchase_date DATE NULL,
    purchase_cost DECIMAL(15,2) NULL,
    location VARCHAR(255) NULL,
    notes TEXT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    INDEX idx_dim_physical_assets_tenant (tenant_id),
    INDEX idx_dim_physical_assets_status (tenant_id, status),
    INDEX idx_dim_physical_assets_asset_tag (tenant_id, asset_tag)
);

-- ----- fact_leads.sql -----
USE `stacie_Aggie_v1.0`;

CREATE TABLE IF NOT EXISTS fact_leads (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    first_name VARCHAR(100) NOT NULL,
    last_name VARCHAR(100) NOT NULL,
    email VARCHAR(255) NULL,
    phone VARCHAR(50) NULL,
    company_name VARCHAR(255) NULL,
    source VARCHAR(100) NULL,
    status VARCHAR(50) NOT NULL DEFAULT 'New',
    score INT NULL,
    assigned_to INT NULL,
    notes TEXT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    INDEX idx_fact_leads_tenant (tenant_id),
    INDEX idx_fact_leads_status (tenant_id, status),
    INDEX idx_fact_leads_assigned (assigned_to)
);

-- ----- fact_meetings.sql -----
USE `stacie_Aggie_v1.0`;

CREATE TABLE IF NOT EXISTS fact_meetings (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    title VARCHAR(255) NOT NULL,
    description TEXT NULL,
    meeting_date DATE NOT NULL,
    start_time TIME NULL,
    end_time TIME NULL,
    location VARCHAR(255) NULL,
    organizer_id INT NULL,
    attendees TEXT NULL,
    status VARCHAR(50) NOT NULL DEFAULT 'Scheduled',
    notes TEXT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    INDEX idx_fact_meetings_tenant (tenant_id),
    INDEX idx_fact_meetings_status (tenant_id, status),
    INDEX idx_fact_meetings_organizer (organizer_id)
);

-- ----- fact_appointments.sql -----
USE `stacie_Aggie_v1.0`;

CREATE TABLE IF NOT EXISTS fact_appointments (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    title VARCHAR(255) NOT NULL,
    description TEXT NULL,
    appointment_date DATE NOT NULL,
    start_time TIME NULL,
    duration_minutes INT NULL,
    location VARCHAR(255) NULL,
    client_id INT NULL,
    assigned_to INT NULL,
    status VARCHAR(50) NOT NULL DEFAULT 'Scheduled',
    notes TEXT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    INDEX idx_fact_appointments_tenant (tenant_id),
    INDEX idx_fact_appointments_status (tenant_id, status),
    INDEX idx_fact_appointments_client (client_id),
    INDEX idx_fact_appointments_assigned (assigned_to)
);

-- ----- fact_campaigns.sql -----
USE `stacie_Aggie_v1.0`;

CREATE TABLE IF NOT EXISTS fact_campaigns (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    name VARCHAR(255) NOT NULL,
    type VARCHAR(100) NOT NULL,
    status VARCHAR(50) NOT NULL DEFAULT 'Draft',
    start_date DATE NULL,
    end_date DATE NULL,
    budget DECIMAL(15,2) NULL,
    actual_spend DECIMAL(15,2) NULL DEFAULT 0.00,
    target_audience VARCHAR(255) NULL,
    owner_id INT NULL,
    notes TEXT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    INDEX idx_fact_campaigns_tenant (tenant_id),
    INDEX idx_fact_campaigns_status (tenant_id, status),
    INDEX idx_fact_campaigns_owner (owner_id)
);

-- ----- fact_asset_allocations.sql -----
USE `stacie_Aggie_v1.0`;

CREATE TABLE IF NOT EXISTS fact_asset_allocations (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    asset_id INT NOT NULL,
    employee_id INT NULL,
    assigned_to_name VARCHAR(255) NULL,
    allocation_date DATE NOT NULL,
    return_date DATE NULL,
    status VARCHAR(50) NOT NULL DEFAULT 'Active',
    notes TEXT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    INDEX idx_fact_asset_allocations_tenant (tenant_id),
    INDEX idx_fact_asset_allocations_asset (tenant_id, asset_id),
    INDEX idx_fact_asset_allocations_employee (employee_id),
    INDEX idx_fact_asset_allocations_status (tenant_id, status)
);

-- ----- fact_vat.sql -----
USE `stacie_Aggie_v1.0`;

CREATE TABLE IF NOT EXISTS fact_vat (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    period VARCHAR(20) NOT NULL,
    currency VARCHAR(10) NOT NULL DEFAULT 'USD',
    sales_vat DECIMAL(15,2) NOT NULL DEFAULT 0.00,
    purchase_vat DECIMAL(15,2) NOT NULL DEFAULT 0.00,
    net_vat DECIMAL(15,2) NOT NULL DEFAULT 0.00,
    due_date DATE NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'Draft',
    notes TEXT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    INDEX idx_fact_vat_tenant (tenant_id),
    INDEX idx_fact_vat_period (tenant_id, period)
);

-- ----- add_financial_entity_contact_fields.sql -----
USE `stacie_Aggie_v1.0`;

-- Adds bank/credit-card contact & address fields to dim_financial_entities (2026-07-16).
-- Purely additive — all columns NULL, no existing column/type touched, no view or stored
-- procedure references these fields today (checked vw_statement_reconciliation_detail and
-- sp_process_ai_bank_statement, neither selects/inserts them).

SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_financial_entities' AND COLUMN_NAME = 'bank_contact_number'
);
SET @sql = IF(@col_exists = 0,
    'ALTER TABLE dim_financial_entities ADD COLUMN bank_contact_number VARCHAR(50) NULL AFTER bank_or_issuer_name',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_financial_entities' AND COLUMN_NAME = 'bank_address'
);
SET @sql = IF(@col_exists = 0,
    'ALTER TABLE dim_financial_entities ADD COLUMN bank_address VARCHAR(500) NULL AFTER bank_contact_number',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_financial_entities' AND COLUMN_NAME = 'bank_fax_number'
);
SET @sql = IF(@col_exists = 0,
    'ALTER TABLE dim_financial_entities ADD COLUMN bank_fax_number VARCHAR(50) NULL AFTER bank_address',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_financial_entities' AND COLUMN_NAME = 'bank_email'
);
SET @sql = IF(@col_exists = 0,
    'ALTER TABLE dim_financial_entities ADD COLUMN bank_email VARCHAR(255) NULL AFTER bank_fax_number',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_financial_entities' AND COLUMN_NAME = 'connected_phone_no'
);
SET @sql = IF(@col_exists = 0,
    'ALTER TABLE dim_financial_entities ADD COLUMN connected_phone_no VARCHAR(50) NULL AFTER bank_email',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_financial_entities' AND COLUMN_NAME = 'connected_email'
);
SET @sql = IF(@col_exists = 0,
    'ALTER TABLE dim_financial_entities ADD COLUMN connected_email VARCHAR(255) NULL AFTER connected_phone_no',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_financial_entities' AND COLUMN_NAME = 'given_address'
);
SET @sql = IF(@col_exists = 0,
    'ALTER TABLE dim_financial_entities ADD COLUMN given_address VARCHAR(500) NULL AFTER connected_email',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

-- ----- add_business_extended_fields.sql -----
USE `stacie_Aggie_v1.0`;

-- Adds business code, tax/registration id, notes, and active-processes fields to
-- `businesses` (2026-07-25). The frontend edit form already reads/writes these
-- fields; they were missing from the table, so they were silently dropped on
-- both save and load. Purely additive — all columns NULL, no existing column
-- touched. `active_processes` stores a comma-separated list of process keys
-- (CRM, FINANCE, HRM, PROJECT, PROCUREMENT, REAL_ESTATE, LEGAL), matching the
-- existing `dim_subscription_plans.modules text` convention.

SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'businesses' AND COLUMN_NAME = 'code'
);
SET @sql = IF(@col_exists = 0,
    'ALTER TABLE businesses ADD COLUMN code VARCHAR(20) NULL AFTER name',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'businesses' AND COLUMN_NAME = 'tax_id'
);
SET @sql = IF(@col_exists = 0,
    'ALTER TABLE businesses ADD COLUMN tax_id VARCHAR(50) NULL AFTER address',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'businesses' AND COLUMN_NAME = 'notes'
);
SET @sql = IF(@col_exists = 0,
    'ALTER TABLE businesses ADD COLUMN notes VARCHAR(1000) NULL AFTER status',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'businesses' AND COLUMN_NAME = 'active_processes'
);
SET @sql = IF(@col_exists = 0,
    'ALTER TABLE businesses ADD COLUMN active_processes VARCHAR(255) NULL AFTER notes',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

-- ----- add_lead_contact_link.sql -----
USE `stacie_Aggie_v1.0`;

-- Links a Lead to a Contact (2026-07-25). Mirrors the existing fact_opportunities.contact_id
-- FK-to-dim_contacts pattern. Purely additive — nullable column, no existing column touched.

SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fact_leads' AND COLUMN_NAME = 'contact_id'
);
SET @sql = IF(@col_exists = 0,
    'ALTER TABLE fact_leads ADD COLUMN contact_id INT NULL AFTER company_name',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

SET @fk_exists = (
    SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fact_leads' AND CONSTRAINT_NAME = 'fk_fact_leads_contact'
);
SET @sql = IF(@fk_exists = 0,
    'ALTER TABLE fact_leads ADD CONSTRAINT fk_fact_leads_contact FOREIGN KEY (contact_id) REFERENCES dim_contacts(id)',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

-- ----- add_lead_service_link_and_description.sql -----
USE `stacie_Aggie_v1.0`;

-- Adds a Lead -> Service link (dim_services) and a Lead description field (2026-07-25).
-- dim_services/dim_service_types/dim_service_packages already exist in the schema but were
-- never wired to any entity/API/UI — this activates dim_services as a lookup source for
-- leads. Purely additive — nullable columns, no existing column touched.

SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fact_leads' AND COLUMN_NAME = 'service_id'
);
SET @sql = IF(@col_exists = 0,
    'ALTER TABLE fact_leads ADD COLUMN service_id INT NULL AFTER contact_id',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

SET @fk_exists = (
    SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fact_leads' AND CONSTRAINT_NAME = 'fk_fact_leads_service'
);
SET @sql = IF(@fk_exists = 0,
    'ALTER TABLE fact_leads ADD CONSTRAINT fk_fact_leads_service FOREIGN KEY (service_id) REFERENCES dim_services(id)',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fact_leads' AND COLUMN_NAME = 'description'
);
SET @sql = IF(@col_exists = 0,
    'ALTER TABLE fact_leads ADD COLUMN description TEXT NULL AFTER notes',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

-- ----- fix_all_business_id_columns.sql -----
-- =============================================================================
-- Adds the business_id column to every table whose JPA entity maps a Business
-- relation (@JoinColumn(name="business_id")) but whose column is absent in older
-- dev databases created from the pre-business_id schema dump. Missing columns
-- cause runtime errors like:
--   "Unknown column 'c1_0.business_id' in 'field list'"
-- on finance list endpoints (wallets, invoices, etc.).
--
-- Idempotent: each ALTER is guarded on information_schema, so re-running is a
-- no-op on databases that already have the column.
--
-- Deploy: mysql -u root -p `stacie_Aggie_v1.0` < db/fix_all_business_id_columns.sql
-- =============================================================================
USE `stacie_Aggie_v1.0`;
SET @db := DATABASE();

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE dim_companies ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_companies' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE dim_contacts ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_contacts' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE dim_expense_categories ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_expense_categories' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE dim_expense_subcategories ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_expense_subcategories' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE dim_legal_assets ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_legal_assets' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE dim_projects ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_projects' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE dim_real_estate_assets ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_real_estate_assets' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE dim_sales_agents ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_sales_agents' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE dim_standardized_categories ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_standardized_categories' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE dim_suppliers ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_suppliers' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE dim_territories ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_territories' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE dim_wallets ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_wallets' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE fact_commissions ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_commissions' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE fact_compliance_logs ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_compliance_logs' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE fact_forecasting ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_forecasting' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE fact_invoices ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_invoices' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE fact_ledger_entries ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_ledger_entries' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE fact_meetings ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_meetings' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE fact_opportunities ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_opportunities' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE fact_opportunity_logs ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_opportunity_logs' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE fact_project_tasks ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_project_tasks' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE fact_transactions ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_transactions' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE fact_vat ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_vat' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- ----- fix_finance_business_id_columns.sql -----
-- =============================================================================
-- Adds the business_id column to finance fact tables whose JPA entities map a
-- Business relation and whose list/create queries filter on business_id. Some
-- older dev databases predate this column, causing
--   "Unknown column 'fs1_0.business_id'"  on bank/CC statement + transaction lists.
-- Guarded so environments that already have the column are unaffected.
-- Deploy: mysql -u root -p `stacie_Aggie_v1.0` < db/fix_finance_business_id_columns.sql
-- =============================================================================
USE `stacie_Aggie_v1.0`;
SET @db := DATABASE();

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE fact_statements ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_statements' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE fact_statement_transactions ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_statement_transactions' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- ----- fix_user_module_access.sql -----
USE `stacie_Aggie_v1.0`;

-- Fixes QA Bug #3: the UserModuleAccess JPA entity expects columns `module`,
-- `is_deleted`, `created_at`, `updated_at` on user_module_access, but the table
-- as originally created only had (id, user_id, module_name, access_level,
-- tenant_id). Every existsBy.../findBy...IsDeletedFalse query threw
-- "Unknown column" and TENANT_MANAGER was locked out of every module-gated
-- endpoint as a result. Align the table to the entity rather than the other
-- way around, since grant/revoke logic in TenantModuleService depends on
-- soft-delete semantics (revoke = is_deleted=1, not a hard delete).

SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_module_access' AND COLUMN_NAME = 'module_name'
);
SET @sql = IF(@col_exists > 0,
    'ALTER TABLE user_module_access CHANGE COLUMN module_name module VARCHAR(50) NOT NULL',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_module_access' AND COLUMN_NAME = 'created_at'
);
SET @sql = IF(@col_exists = 0,
    'ALTER TABLE user_module_access ADD COLUMN created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6)',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_module_access' AND COLUMN_NAME = 'updated_at'
);
SET @sql = IF(@col_exists = 0,
    'ALTER TABLE user_module_access ADD COLUMN updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_module_access' AND COLUMN_NAME = 'is_deleted'
);
SET @sql = IF(@col_exists = 0,
    'ALTER TABLE user_module_access ADD COLUMN is_deleted TINYINT NOT NULL DEFAULT 0',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

-- ----- fix_dim_expense_categories.sql -----
USE `stacie_Aggie_v1.0`;

-- Found during Finance re-test (2026-07-12): DimExpenseCategory entity declares an is_deleted
-- field (used by 5 of 8 DimExpenseCategoryRepository query methods, e.g.
-- findByIdAndTenantIdAndIsDeletedFalse), but dim_expense_categories never had that column —
-- same schema-drift pattern as Bug #3 (user_module_access). Every soft-delete-aware query against
-- this table failed with "Unknown column 'is_deleted' in 'field list'", including transitively
-- whenever FactTransaction/FactLedgerEntry eagerly fetched their linked category/subcategory.

SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_expense_categories' AND COLUMN_NAME = 'is_deleted'
);
SET @sql = IF(@col_exists = 0,
    'ALTER TABLE dim_expense_categories ADD COLUMN is_deleted TINYINT NOT NULL DEFAULT 0',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

-- ----- fix_sp_bulk_create_tenant_users.sql -----
USE `stacie_Aggie_v1.0`;

-- Fixes QA Bug #4: sp_bulk_create_tenant_users' JSON contract (first/last/hash/
-- role/scope/dept) never matched what UserService.createUserViaSp() actually
-- sends (firstName/lastName/passwordHash/passwordSalt/userType/roleId/
-- moduleAccess), so v_role_name was always NULL, the INSERT INTO roles
-- violated its NOT NULL constraint, the blanket EXIT HANDLER swallowed the
-- error silently, and every tenant-admin/tenant-manager creation failed with
-- an opaque 500. Also: AuthProcedureRepository declares parameters
-- (p_tenant_id, p_users_json, p_created_by) plus an OUT p_created_count,
-- which never matched this procedure's (p_tenant_id, p_users_json,
-- p_creator_id) with no OUT param either — fixed here too.
--
-- Role creation now happens in Java (UserService.findOrCreateRole) before
-- this procedure is called, so roleId is passed directly instead of being
-- resolved/created from a role name inside the procedure.

DROP PROCEDURE IF EXISTS sp_bulk_create_tenant_users;

DELIMITER $$

CREATE PROCEDURE sp_bulk_create_tenant_users(
    IN p_tenant_id INT,
    IN p_users_json JSON,
    /* Example payload element:
    {
      "firstName": "John", "lastName": "Doe", "email": "j@test.com",
      "passwordHash": "$2a$...", "passwordSalt": "uuid", "userType": "TENANT_MANAGER",
      "roleId": 5,
      "moduleAccess": [{"module": "CRM", "accessLevel": "WRITE"}]
    }
    */
    IN p_created_by INT,
    OUT p_created_count INT
)
BEGIN
    DECLARE v_user_count INT DEFAULT 0;
    DECLARE v_module_count INT DEFAULT 0;
    DECLARE i INT DEFAULT 0;
    DECLARE j INT DEFAULT 0;

    DECLARE v_first VARCHAR(100);
    DECLARE v_last VARCHAR(100);
    DECLARE v_email VARCHAR(255);
    DECLARE v_hash VARCHAR(512);
    DECLARE v_salt VARCHAR(255);
    DECLARE v_user_type VARCHAR(20);
    DECLARE v_role_id INT;
    DECLARE v_new_user_id INT;
    DECLARE v_module_json JSON;
    DECLARE v_module_name VARCHAR(50);
    DECLARE v_access_level VARCHAR(20);

    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;

    START TRANSACTION;

    SET v_user_count = JSON_LENGTH(p_users_json);

    WHILE i < v_user_count DO
        SET v_first     = JSON_UNQUOTE(JSON_EXTRACT(p_users_json, CONCAT('$[', i, '].firstName')));
        SET v_last      = JSON_UNQUOTE(JSON_EXTRACT(p_users_json, CONCAT('$[', i, '].lastName')));
        SET v_email     = JSON_UNQUOTE(JSON_EXTRACT(p_users_json, CONCAT('$[', i, '].email')));
        SET v_hash       = JSON_UNQUOTE(JSON_EXTRACT(p_users_json, CONCAT('$[', i, '].passwordHash')));
        SET v_salt       = JSON_UNQUOTE(JSON_EXTRACT(p_users_json, CONCAT('$[', i, '].passwordSalt')));
        SET v_user_type  = JSON_UNQUOTE(JSON_EXTRACT(p_users_json, CONCAT('$[', i, '].userType')));
        SET v_role_id    = JSON_EXTRACT(p_users_json, CONCAT('$[', i, '].roleId'));
        SET v_module_json = JSON_EXTRACT(p_users_json, CONCAT('$[', i, '].moduleAccess'));

        INSERT INTO users (
            tenant_id, first_name, last_name, email, password_hash, password_salt,
            role_id, status, is_super_admin, user_type, is_enabled, created_by
        ) VALUES (
            p_tenant_id, v_first, v_last, v_email, v_hash, v_salt,
            v_role_id, 'Active', 0, v_user_type, b'1', p_created_by
        );

        SET v_new_user_id = LAST_INSERT_ID();

        IF v_module_json IS NOT NULL THEN
            SET v_module_count = JSON_LENGTH(v_module_json);
            SET j = 0;
            WHILE j < v_module_count DO
                SET v_module_name  = JSON_UNQUOTE(JSON_EXTRACT(v_module_json, CONCAT('$[', j, '].module')));
                SET v_access_level = JSON_UNQUOTE(JSON_EXTRACT(v_module_json, CONCAT('$[', j, '].accessLevel')));
                INSERT INTO user_module_access (user_id, module, access_level, tenant_id, is_deleted)
                VALUES (v_new_user_id, v_module_name, IFNULL(v_access_level, 'READ'), p_tenant_id, 0);
                SET j = j + 1;
            END WHILE;
        END IF;

        SET i = i + 1;
    END WHILE;

    SET p_created_count = v_user_count;

    COMMIT;
END$$

DELIMITER ;

-- ----- fix_finance_hrm_platform_procedures.sql -----
USE `stacie_Aggie_v1.0`;

-- Consolidated fix for QA Bugs #7 (stored-procedure contract drift), #9 (NOT NULL columns never
-- populated), and the schema addition needed for #7's employee-termination fix. Covers the
-- CRM/Finance core flow (Opportunity, Invoice, Pay) and the HRM/Platform-Admin procedures that
-- need more than a one-parameter fix (see plans/java-fixes-plan.md for the simpler cases that only
-- need a Java-side change, no SQL). Safe to re-run — every ALTER is idempotent and every
-- CREATE PROCEDURE is preceded by DROP PROCEDURE IF EXISTS.
--
-- Pair this with the matching Java changes in plans/java-fixes-plan.md before deploying — the
-- Java *ProcedureRepository classes must declare the exact same parameter list, in the same order,
-- as each procedure below, or SimpleJdbcCall will bind values to the wrong positions.

-- =============================================================================================
-- 1. sp_create_opportunity — was missing p_tenant_id, ignored 6 fields Java already sends, and
--    never populated fact_opportunities.full_name (NOT NULL, no default) — see Bug #9.
-- =============================================================================================
DROP PROCEDURE IF EXISTS sp_create_opportunity;

DELIMITER $$

CREATE PROCEDURE sp_create_opportunity(
    IN p_tenant_id INT,
    IN p_company_id INT,
    IN p_contact_id INT,
    IN p_title VARCHAR(255),
    IN p_status VARCHAR(50),
    IN p_source VARCHAR(100),
    IN p_probability INT,
    IN p_deal_value DECIMAL(15,2),
    IN p_currency VARCHAR(10),
    IN p_assigned_to INT,
    IN p_expected_close_date DATE,
    IN p_notes TEXT,
    IN p_created_by INT,
    OUT p_opportunity_id BIGINT
)
BEGIN
    DECLARE v_full_name VARCHAR(255);
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;

    START TRANSACTION;

    SET v_full_name = p_title;
    IF p_contact_id IS NOT NULL THEN
        SELECT CONCAT(first_name, ' ', last_name) INTO v_full_name
        FROM dim_contacts
        WHERE id = p_contact_id AND tenant_id = p_tenant_id;
        IF v_full_name IS NULL THEN
            SET v_full_name = p_title;
        END IF;
    END IF;

    INSERT INTO fact_opportunities (
        tenant_id, company_id, contact_id, full_name, title, deal_title, status, source,
        probability, deal_value, currency, assigned_to, expected_close_date, notes, created_by
    ) VALUES (
        p_tenant_id, p_company_id, p_contact_id, v_full_name, p_title, p_title, p_status, p_source,
        p_probability, p_deal_value, p_currency, p_assigned_to, p_expected_close_date, p_notes, p_created_by
    );

    SET p_opportunity_id = LAST_INSERT_ID();
    COMMIT;
END$$

DELIMITER ;

-- =============================================================================================
-- 2. sp_create_invoice_with_ledger — was missing p_tenant_id, ignored 5 fields Java already
--    sends, and never populated fact_invoices.client_name (NOT NULL, no default) — see Bug #9.
-- =============================================================================================
DROP PROCEDURE IF EXISTS sp_create_invoice_with_ledger;

DELIMITER $$

CREATE PROCEDURE sp_create_invoice_with_ledger(
    IN p_tenant_id INT,
    IN p_opportunity_id BIGINT,
    IN p_wallet_id INT,
    IN p_client_id INT,
    IN p_invoice_no VARCHAR(100),
    IN p_amount DECIMAL(15,2),
    IN p_tax_amount DECIMAL(15,2),
    IN p_currency VARCHAR(10),
    IN p_issue_date DATE,
    IN p_due_date DATE,
    IN p_notes TEXT,
    IN p_created_by INT,
    OUT p_invoice_id BIGINT
)
BEGIN
    DECLARE v_client_name VARCHAR(255);
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;

    START TRANSACTION;

    SELECT company_name INTO v_client_name
    FROM dim_companies
    WHERE id = p_client_id AND tenant_id = p_tenant_id;

    INSERT INTO fact_invoices (
        tenant_id, opportunity_id, wallet_id, client_id, invoice_no, client_name, amount,
        tax_amount, currency, status, issue_date, due_date, notes, created_by
    ) VALUES (
        p_tenant_id, p_opportunity_id, p_wallet_id, p_client_id, p_invoice_no, v_client_name,
        p_amount, p_tax_amount, p_currency, 'Draft', p_issue_date, p_due_date, p_notes, p_created_by
    );

    SET p_invoice_id = LAST_INSERT_ID();

    INSERT INTO fact_ledger_entries (
        tenant_id, wallet_id, entry_type, amount, reference_entity, reference_id, created_by
    ) VALUES (
        p_tenant_id, p_wallet_id, 'Debit', p_amount + p_tax_amount, 'Invoice', p_invoice_id, p_created_by
    );

    COMMIT;
END$$

DELIMITER ;

-- =============================================================================================
-- 3. sp_pay_invoice — discovered during Finance re-test (2026-07-12): fact_transactions.description
--    is NOT NULL with no default, and this procedure's INSERT never populated it — same schema-
--    drift pattern as Bug #9. Parameter list is unchanged from the original procedure; only the
--    fact_transactions INSERT is fixed to supply a description.
-- =============================================================================================
DROP PROCEDURE IF EXISTS sp_pay_invoice;

DELIMITER $$

CREATE PROCEDURE sp_pay_invoice(
    IN p_tenant_id INT,
    IN p_invoice_id BIGINT,
    IN p_client_wallet_id INT,
    IN p_corp_wallet_id INT,
    IN p_updated_by INT
)
BEGIN
    DECLARE v_total_amount DECIMAL(15,2);
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;

    START TRANSACTION;

    SELECT (amount + tax_amount) INTO v_total_amount FROM fact_invoices WHERE id = p_invoice_id;

    UPDATE fact_invoices SET status = 'Paid', updated_by = p_updated_by
    WHERE id = p_invoice_id AND tenant_id = p_tenant_id;

    INSERT INTO fact_transactions (
        tenant_id, from_wallet_id, to_wallet_id, amount, description, reference_entity,
        reference_id, transaction_type, status, created_by
    ) VALUES (
        p_tenant_id, p_client_wallet_id, p_corp_wallet_id, v_total_amount,
        CONCAT('Payment for Invoice #', p_invoice_id), 'Invoice', p_invoice_id, 'Income',
        'Completed', p_updated_by
    );

    INSERT INTO fact_ledger_entries (tenant_id, wallet_id, entry_type, amount, reference_entity, reference_id, created_by)
    VALUES (p_tenant_id, p_client_wallet_id, 'Debit', v_total_amount, 'Invoice', p_invoice_id, p_updated_by);

    COMMIT;
END$$

DELIMITER ;

-- =============================================================================================
-- 4. sp_onboard_employee_full — was missing p_tenant_id (had it, but 6 other fields Java sends
--    were dropped: phone, manager, location, currency, password_salt, and department/designation
--    were never resolved from their ID columns to the display-string columns dim_employees also
--    has). Rewritten to accept the full contract OnboardEmployeePayload already sends.
-- =============================================================================================
DROP PROCEDURE IF EXISTS sp_onboard_employee_full;

DELIMITER $$

CREATE PROCEDURE sp_onboard_employee_full(
    IN p_tenant_id INT,
    IN p_first_name VARCHAR(100),
    IN p_last_name VARCHAR(100),
    IN p_email VARCHAR(255),
    IN p_phone VARCHAR(50),
    IN p_department_id INT,
    IN p_designation_id INT,
    IN p_contract_type VARCHAR(50),
    IN p_salary DECIMAL(15,2),
    IN p_currency VARCHAR(10),
    IN p_join_date DATE,
    IN p_manager VARCHAR(100),
    IN p_location VARCHAR(100),
    IN p_contract_start DATE,
    IN p_contract_end DATE,
    IN p_allowances_json JSON,
    IN p_create_user TINYINT,
    IN p_password_hash VARCHAR(512),
    IN p_password_salt VARCHAR(255),
    IN p_role_id INT,
    IN p_created_by INT,
    OUT p_employee_id INT
)
BEGIN
    DECLARE v_user_id INT DEFAULT NULL;
    DECLARE v_department_name VARCHAR(100) DEFAULT NULL;
    DECLARE v_job_title VARCHAR(100) DEFAULT NULL;
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;

    START TRANSACTION;

    IF p_department_id IS NOT NULL THEN
        SELECT department_name INTO v_department_name
        FROM dim_departments
        WHERE id = p_department_id AND tenant_id = p_tenant_id;
    END IF;

    IF p_designation_id IS NOT NULL THEN
        SELECT title INTO v_job_title
        FROM dim_designations
        WHERE id = p_designation_id AND tenant_id = p_tenant_id;
    END IF;

    IF p_create_user = 1 THEN
        INSERT INTO users (
            tenant_id, first_name, last_name, email, password_hash, password_salt, role_id,
            is_enabled, created_by
        ) VALUES (
            p_tenant_id, p_first_name, p_last_name, p_email, p_password_hash, p_password_salt,
            p_role_id, b'1', p_created_by
        );
        SET v_user_id = LAST_INSERT_ID();
    END IF;

    INSERT INTO dim_employees (
        tenant_id, user_id, department_id, designation_id, first_name, last_name, email, phone,
        department, job_title, contract_type, salary, currency, join_date, manager, location,
        created_by
    ) VALUES (
        p_tenant_id, v_user_id, p_department_id, p_designation_id, p_first_name, p_last_name,
        p_email, p_phone, v_department_name, v_job_title, p_contract_type, p_salary, p_currency,
        p_join_date, p_manager, p_location, p_created_by
    );
    SET p_employee_id = LAST_INSERT_ID();

    INSERT INTO dim_employee_contracts (
        tenant_id, employee_id, start_date, end_date, allowances_json, created_by
    ) VALUES (
        p_tenant_id, p_employee_id, p_contract_start, p_contract_end, p_allowances_json, p_created_by
    );

    INSERT INTO dim_wallets (
        tenant_id, wallet_name, owner_type, employee_id, currency, created_by
    ) VALUES (
        p_tenant_id, CONCAT(p_first_name, ' ', p_last_name, ' Wallet'), 'Employee', p_employee_id,
        p_currency, p_created_by
    );

    COMMIT;
END$$

DELIMITER ;

-- =============================================================================================
-- 5. sp_terminate_employee — dim_employees has no columns to record termination_date/reason;
--    add them (idempotent), then rewrite the procedure to store what TerminateEmployeePayload
--    already sends instead of silently dropping it.
-- =============================================================================================
SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_employees' AND COLUMN_NAME = 'termination_date'
);
SET @sql = IF(@col_exists = 0,
    'ALTER TABLE dim_employees ADD COLUMN termination_date DATE NULL',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_employees' AND COLUMN_NAME = 'termination_reason'
);
SET @sql = IF(@col_exists = 0,
    'ALTER TABLE dim_employees ADD COLUMN termination_reason VARCHAR(500) NULL',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

DROP PROCEDURE IF EXISTS sp_terminate_employee;

DELIMITER $$

CREATE PROCEDURE sp_terminate_employee(
    IN p_tenant_id INT,
    IN p_employee_id INT,
    IN p_termination_date DATE,
    IN p_reason VARCHAR(500),
    IN p_updated_by INT
)
BEGIN
    DECLARE v_user_id INT;
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;

    START TRANSACTION;

    SELECT user_id INTO v_user_id FROM dim_employees WHERE id = p_employee_id;

    UPDATE dim_employees
    SET status = 'Terminated',
        is_deleted = 1,
        termination_date = p_termination_date,
        termination_reason = p_reason,
        updated_by = p_updated_by
    WHERE id = p_employee_id AND tenant_id = p_tenant_id;

    UPDATE dim_wallets
    SET status = 'Frozen', updated_by = p_updated_by
    WHERE employee_id = p_employee_id AND tenant_id = p_tenant_id;

    IF v_user_id IS NOT NULL THEN
        UPDATE users SET is_enabled = b'0', is_deleted = 1, updated_by = p_updated_by
        WHERE id = v_user_id AND tenant_id = p_tenant_id;

        UPDATE user_api_keys SET is_deleted = 1 WHERE user_id = v_user_id;
    END IF;

    COMMIT;
END$$

DELIMITER ;

-- =============================================================================================
-- 6. sp_suspend_tenant — Java already sends p_reason/p_suspend_until, but the procedure ignored
--    both. Store them in platform_audit_logs.description (existing JSON audit-trail column)
--    rather than adding new tenants columns.
-- =============================================================================================
DROP PROCEDURE IF EXISTS sp_suspend_tenant;

DELIMITER $$

CREATE PROCEDURE sp_suspend_tenant(
    IN p_tenant_id INT,
    IN p_reason VARCHAR(500),
    IN p_suspend_until DATE,
    IN p_performed_by INT
)
BEGIN
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;

    START TRANSACTION;

    UPDATE tenants SET status = 'Suspended', updated_by = p_performed_by WHERE id = p_tenant_id;
    UPDATE users SET is_enabled = b'0', status = 'Inactive', updated_by = p_performed_by
    WHERE tenant_id = p_tenant_id;

    INSERT INTO platform_audit_logs (tenant_id, action_type, performed_by, description)
    VALUES (
        p_tenant_id, 'SUSPEND_TENANT', p_performed_by,
        JSON_OBJECT('status', 'Suspended', 'reason', p_reason, 'suspendUntil', p_suspend_until)
    );

    COMMIT;
END$$

DELIMITER ;

-- =============================================================================================
-- Not included here (no SQL change needed — Java-only fixes, see plans/java-fixes-plan.md):
--   sp_soft_delete_user, sp_soft_delete_company, sp_link_employee_to_user,
--   sp_update_opportunity_status, sp_assign_role_to_user (Tier 2 — Java just needs to send a
--   parameter it already has, or one already-defined-elsewhere value, the procedures are correct
--   as deployed) and sp_snapshot_tenant_usage (Java needs to match the procedure's simpler
--   single-tenant contract, not the other way around).
-- =============================================================================================

-- ----- fix_finance_wallet_procedures.sql -----
USE `stacie_Aggie_v1.0`;

-- Fixes two Finance stored-procedure contract bugs found in a Finance stored-procedure audit
-- (companion to fix_finance_hrm_platform_procedures.sql, applied right after it in
-- apply-migrations.sh). Safe to re-run — every CREATE PROCEDURE is preceded by
-- DROP PROCEDURE IF EXISTS.
--
-- Pair this with the matching Java changes: FinanceProcedureRepository.transferWallet() and
-- CrmProcedureRepository (new voidInvoiceCall) must declare the exact same parameter list, in the
-- same order, as each procedure below, or SimpleJdbcCall will bind values to the wrong positions.

-- =============================================================================================
-- 1. sp_transfer_wallet — Java's FinanceProcedureRepository declared 4 IN params (no tenant) +
--    1 OUT (p_transaction_id), but the live procedure took 5 IN params starting with
--    p_tenant_id and had NO OUT parameter at all (it computed v_transaction_id internally via
--    LAST_INSERT_ID() but never surfaced it). Every call from Java was therefore already broken
--    (SimpleJdbcCall param-count/order mismatch). Fixed to add the missing p_tenant_id IN param
--    (kept first, matching the convention every other procedure in this codebase uses) and to
--    add the OUT parameter the Java side already expects.
-- =============================================================================================
DROP PROCEDURE IF EXISTS `sp_transfer_wallet`;

DELIMITER $$

CREATE PROCEDURE `sp_transfer_wallet`(
    IN p_tenant_id INT,
    IN p_from_wallet_id INT,
    IN p_to_wallet_id INT,
    IN p_amount DECIMAL(15,2),
    IN p_created_by INT,
    OUT p_transaction_id BIGINT
)
BEGIN
    DECLARE v_transaction_id BIGINT;
    DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;
    START TRANSACTION;
    INSERT INTO fact_transactions (tenant_id, from_wallet_id, to_wallet_id, amount, description, transaction_type, status, created_by)
    VALUES (p_tenant_id, p_from_wallet_id, p_to_wallet_id, p_amount,
            CONCAT('Transfer from wallet #', p_from_wallet_id, ' to wallet #', p_to_wallet_id),
            'Transfer', 'Completed', p_created_by);
    SET v_transaction_id = LAST_INSERT_ID();
    INSERT INTO fact_ledger_entries (tenant_id, wallet_id, entry_type, amount, reference_entity, reference_id, created_by)
    VALUES (p_tenant_id, p_from_wallet_id, 'Debit', p_amount, 'Transfer', v_transaction_id, p_created_by);
    INSERT INTO fact_ledger_entries (tenant_id, wallet_id, entry_type, amount, reference_entity, reference_id, created_by)
    VALUES (p_tenant_id, p_to_wallet_id, 'Credit', p_amount, 'Transfer', v_transaction_id, p_created_by);
    UPDATE dim_wallets SET balance = balance - p_amount WHERE id = p_from_wallet_id;
    UPDATE dim_wallets SET balance = balance + p_amount WHERE id = p_to_wallet_id;
    SET p_transaction_id = v_transaction_id;
    COMMIT;
END$$

DELIMITER ;

-- =============================================================================================
-- 2. sp_void_invoice — on a Paid invoice, the old body debited `v_wallet_id` (fact_invoices.
--    wallet_id, i.e. the CLIENT's wallet per InvoiceService.createInvoice) a second time, with
--    an inline comment mislabeling it "the affected corporate wallet". sp_pay_invoice's effect at
--    payment time is: debit the client wallet, credit the corp wallet. Voiding a paid invoice must
--    be the exact mirror: credit the client wallet back, debit the corp wallet back — not debit
--    the client wallet again. Fixed to accept a new p_corp_wallet_id IN param and, on a Paid
--    invoice, insert a Credit ledger entry + balance increase for the client wallet and a Debit
--    ledger entry + balance decrease for the corp wallet.
-- =============================================================================================
-- =============================================================================================
-- 3. sp_pay_commission — same missing-`description` bug as sp_transfer_wallet above
--    (fact_transactions.description is NOT NULL with no default; this INSERT never supplied
--    one). Discovered by directly testing sp_transfer_wallet after this file's first pass and
--    checking sibling procedures with the same INSERT shape. Everything else in this procedure
--    was already correct (both ledger entries + both wallet balance updates present).
-- =============================================================================================
DROP PROCEDURE IF EXISTS `sp_pay_commission`;

DELIMITER $$

CREATE PROCEDURE `sp_pay_commission`(IN p_tenant_id INT, IN p_commission_id BIGINT, IN p_corp_wallet_id INT, IN p_agent_wallet_id INT, IN p_performed_by INT)
BEGIN
    DECLARE v_amount DECIMAL(15,2);
    DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;
    START TRANSACTION;

    SELECT total INTO v_amount FROM fact_commissions WHERE id = p_commission_id AND tenant_id = p_tenant_id;

    UPDATE fact_commissions SET status = 'Paid', paid_date = CURRENT_DATE, updated_by = p_performed_by WHERE id = p_commission_id AND tenant_id = p_tenant_id;

    INSERT INTO fact_transactions (tenant_id, from_wallet_id, to_wallet_id, amount, description, reference_entity, reference_id, transaction_type, status, created_by)
    VALUES (p_tenant_id, p_corp_wallet_id, p_agent_wallet_id, v_amount,
            CONCAT('Commission payout #', p_commission_id), 'Commission', p_commission_id, 'Transfer', 'Completed', p_performed_by);

    INSERT INTO fact_ledger_entries (tenant_id, wallet_id, entry_type, amount, reference_entity, reference_id, created_by)
    VALUES (p_tenant_id, p_corp_wallet_id, 'Debit', v_amount, 'Commission', p_commission_id, p_performed_by);

    INSERT INTO fact_ledger_entries (tenant_id, wallet_id, entry_type, amount, reference_entity, reference_id, created_by)
    VALUES (p_tenant_id, p_agent_wallet_id, 'Credit', v_amount, 'Commission', p_commission_id, p_performed_by);

    UPDATE dim_wallets SET balance = balance - v_amount WHERE id = p_corp_wallet_id;
    UPDATE dim_wallets SET balance = balance + v_amount WHERE id = p_agent_wallet_id;
    COMMIT;
END$$

DELIMITER ;

DROP PROCEDURE IF EXISTS `sp_void_invoice`;

DELIMITER $$

CREATE PROCEDURE `sp_void_invoice`(
    IN p_tenant_id INT,
    IN p_invoice_id BIGINT,
    IN p_corp_wallet_id INT,
    IN p_voided_by INT
)
BEGIN
    DECLARE v_status VARCHAR(50);
    DECLARE v_amount DECIMAL(15,2);
    DECLARE v_wallet_id INT;
    DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;
    START TRANSACTION;

    SELECT status, (amount + tax_amount), wallet_id INTO v_status, v_amount, v_wallet_id
    FROM fact_invoices WHERE id = p_invoice_id AND tenant_id = p_tenant_id;

    UPDATE fact_invoices SET status = 'Cancelled', updated_by = p_voided_by WHERE id = p_invoice_id AND tenant_id = p_tenant_id;

    -- If Paid previously, we must safely reverse the ledger impact: this is the mirror image of
    -- sp_pay_invoice (which debits the client wallet and credits the corp wallet), so voiding
    -- credits the client wallet back and debits the corp wallet back.
    IF v_status = 'Paid' THEN
        INSERT INTO fact_ledger_entries (tenant_id, wallet_id, entry_type, amount, reference_entity, reference_id, created_by)
        VALUES (p_tenant_id, v_wallet_id, 'Credit', v_amount, 'Void_Invoice', p_invoice_id, p_voided_by);

        INSERT INTO fact_ledger_entries (tenant_id, wallet_id, entry_type, amount, reference_entity, reference_id, created_by)
        VALUES (p_tenant_id, p_corp_wallet_id, 'Debit', v_amount, 'Void_Invoice', p_invoice_id, p_voided_by);

        -- Give the client back the money it paid, and reverse the corresponding credit on the
        -- corporate wallet.
        UPDATE dim_wallets SET balance = balance + v_amount WHERE id = v_wallet_id AND tenant_id = p_tenant_id;
        UPDATE dim_wallets SET balance = balance - v_amount WHERE id = p_corp_wallet_id AND tenant_id = p_tenant_id;
    END IF;
    COMMIT;
END$$

DELIMITER ;

-- ----- fix_pay_invoice_ledger_balance.sql -----
USE `stacie_Aggie_v1.0`;

-- =============================================================================================
-- fix_pay_invoice_ledger_balance.sql — sp_pay_invoice (last defined in
-- fix_finance_hrm_platform_procedures.sql) records a fact_transactions row and a single Debit
-- fact_ledger_entries row against the client wallet, but never:
--   1. inserts the matching Credit ledger entry for the corporate wallet (double-entry requires
--      one Debit + one Credit per movement — sp_transfer_wallet, sp_pay_commission, and
--      sp_void_invoice all do this pair; sp_pay_invoice was missing the Credit half), and
--   2. updates dim_wallets.balance at all, for either wallet.
--
-- Net effect of the bug: marking an invoice Paid never actually moves any money — the client
-- wallet's and corp wallet's balances are untouched. Meanwhile sp_void_invoice (fixed in
-- fix_finance_wallet_procedures.sql) DOES move dim_wallets.balance when reversing a Paid invoice
-- (credits the client wallet back, debits the corp wallet back) — since payment never applied
-- those balance changes in the first place, voiding a paid invoice corrupts both wallets'
-- balances instead of restoring them to their pre-payment state.
--
-- Fixed to mirror sp_void_invoice's reversal exactly: debit the client wallet's balance, credit
-- the corporate wallet's balance, and insert both ledger legs. Also tenant-scopes the initial
-- SELECT (every sibling procedure filters by tenant_id there; this one didn't).
--
-- Safe to re-run: DROP PROCEDURE IF EXISTS precedes the CREATE PROCEDURE.
-- =============================================================================================

DROP PROCEDURE IF EXISTS sp_pay_invoice;

DELIMITER $$

CREATE PROCEDURE sp_pay_invoice(
    IN p_tenant_id INT,
    IN p_invoice_id BIGINT,
    IN p_client_wallet_id INT,
    IN p_corp_wallet_id INT,
    IN p_updated_by INT
)
BEGIN
    DECLARE v_total_amount DECIMAL(15,2);
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;

    START TRANSACTION;

    SELECT (amount + tax_amount) INTO v_total_amount
    FROM fact_invoices WHERE id = p_invoice_id AND tenant_id = p_tenant_id;

    UPDATE fact_invoices SET status = 'Paid', updated_by = p_updated_by
    WHERE id = p_invoice_id AND tenant_id = p_tenant_id;

    INSERT INTO fact_transactions (
        tenant_id, from_wallet_id, to_wallet_id, amount, description, reference_entity,
        reference_id, transaction_type, status, created_by
    ) VALUES (
        p_tenant_id, p_client_wallet_id, p_corp_wallet_id, v_total_amount,
        CONCAT('Payment for Invoice #', p_invoice_id), 'Invoice', p_invoice_id, 'Income',
        'Completed', p_updated_by
    );

    INSERT INTO fact_ledger_entries (tenant_id, wallet_id, entry_type, amount, reference_entity, reference_id, created_by)
    VALUES (p_tenant_id, p_client_wallet_id, 'Debit', v_total_amount, 'Invoice', p_invoice_id, p_updated_by);

    INSERT INTO fact_ledger_entries (tenant_id, wallet_id, entry_type, amount, reference_entity, reference_id, created_by)
    VALUES (p_tenant_id, p_corp_wallet_id, 'Credit', v_total_amount, 'Invoice', p_invoice_id, p_updated_by);

    UPDATE dim_wallets SET balance = balance - v_total_amount WHERE id = p_client_wallet_id AND tenant_id = p_tenant_id;
    UPDATE dim_wallets SET balance = balance + v_total_amount WHERE id = p_corp_wallet_id AND tenant_id = p_tenant_id;

    COMMIT;
END$$

DELIMITER ;

-- ----- fix_hrm_compliance_procedure_signatures.sql -----
USE `stacie_Aggie_v1.0`;

-- Fixes two Java/SQL stored-procedure signature mismatches found in the follow-up stored-
-- procedure audit (sp_process_payroll, sp_log_compliance_event). Both procedures were called by
-- SimpleJdbcCall with a parameter list that no longer matches the procedure actually deployed
-- (full_schema_snapshot.sql) — wrong order, params the procedure doesn't accept, and OUT
-- parameters the procedure never declared. Safe to re-run — every CREATE PROCEDURE is preceded
-- by DROP PROCEDURE IF EXISTS.
--
-- sp_renew_employee_contract and sp_create_vat_record were also audited (registered in
-- AbosProcedureProperties but with zero call sites). Investigation found ContractService and
-- VatService already implement the equivalent create/renew flows directly via JPA, without ever
-- calling these procedures — they are confirmed-obsolete, not half-built, so no SQL change is
-- made for them here. See the Java fix's commit/plan notes for details.

-- =============================================================================================
-- 1. sp_process_payroll — Java (HrmProcedureRepository) declared
--    (tenant_id, employee_id, company_wallet_id, pay_month, base_salary, allowances, deductions,
--    tax, processed_by) + OUT payroll_id, but the deployed procedure is
--    (p_tenant_id, p_employee_id, p_month, p_base_salary, p_allowances, p_deductions, p_tax,
--    p_net_pay, p_corp_wallet_id, p_created_by) with NO out parameter, and expects the caller to
--    have already computed p_net_pay. Also hardens the original bug where a missing employee
--    wallet left the corp-wallet debit with no matching employee-side credit (v_employee_wallet_id
--    would be NULL, the transfer/ledger rows would silently reference a NULL wallet, and the
--    balance UPDATE would touch zero rows) — instead of failing the whole payroll run, this
--    auto-creates the employee's wallet first, mirroring the lazy-create pattern already used by
--    InvoiceService.findOrCreateCorpWallet (Java) / sp_onboard_employee_full (SQL) for the
--    equivalent "wallet doesn't exist yet" case, rather than a hard SIGNAL failure.
-- =============================================================================================
DROP PROCEDURE IF EXISTS sp_process_payroll;

DELIMITER $$

CREATE PROCEDURE sp_process_payroll(
    IN p_tenant_id INT,
    IN p_employee_id INT,
    IN p_month VARCHAR(20),
    IN p_base_salary DECIMAL(15,2),
    IN p_allowances DECIMAL(15,2),
    IN p_deductions DECIMAL(15,2),
    IN p_tax DECIMAL(15,2),
    IN p_net_pay DECIMAL(15,2),
    IN p_corp_wallet_id INT,
    IN p_created_by INT
)
BEGIN
    DECLARE v_employee_wallet_id INT;
    DECLARE v_first_name VARCHAR(100);
    DECLARE v_last_name VARCHAR(100);
    DECLARE v_currency VARCHAR(10);
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;

    START TRANSACTION;

    SELECT id INTO v_employee_wallet_id FROM dim_wallets
    WHERE employee_id = p_employee_id AND tenant_id = p_tenant_id LIMIT 1;

    -- Employee wallets are normally created at onboarding (sp_onboard_employee_full), but for
    -- legacy/edge-case employees that predate that logic, auto-create one now instead of paying
    -- into a black hole.
    IF v_employee_wallet_id IS NULL THEN
        SELECT first_name, last_name, currency INTO v_first_name, v_last_name, v_currency
        FROM dim_employees
        WHERE id = p_employee_id AND tenant_id = p_tenant_id;

        INSERT INTO dim_wallets (tenant_id, wallet_name, owner_type, employee_id, currency, created_by)
        VALUES (
            p_tenant_id, CONCAT(COALESCE(v_first_name, 'Employee'), ' ', COALESCE(v_last_name, p_employee_id), ' Wallet'),
            'Employee', p_employee_id, COALESCE(v_currency, 'USD'), p_created_by
        );
        SET v_employee_wallet_id = LAST_INSERT_ID();
    END IF;

    INSERT INTO fact_payroll (tenant_id, employee_id, month, base_salary, allowances, deductions, tax, net_pay, status, pay_date, created_by)
    VALUES (p_tenant_id, p_employee_id, p_month, p_base_salary, p_allowances, p_deductions, p_tax, p_net_pay, 'Paid', CURRENT_DATE, p_created_by);

    INSERT INTO fact_transactions (tenant_id, from_wallet_id, to_wallet_id, amount, reference_entity, transaction_type, status, created_by)
    VALUES (p_tenant_id, p_corp_wallet_id, v_employee_wallet_id, p_net_pay, 'Payroll', 'Transfer', 'Completed', p_created_by);

    INSERT INTO fact_ledger_entries (tenant_id, wallet_id, entry_type, amount, reference_entity, created_by)
    VALUES (p_tenant_id, p_corp_wallet_id, 'Debit', p_net_pay, 'Payroll', p_created_by);

    INSERT INTO fact_ledger_entries (tenant_id, wallet_id, entry_type, amount, reference_entity, created_by)
    VALUES (p_tenant_id, v_employee_wallet_id, 'Credit', p_net_pay, 'Payroll', p_created_by);

    UPDATE dim_wallets SET balance = balance - p_net_pay WHERE id = p_corp_wallet_id;
    UPDATE dim_wallets SET balance = balance + p_net_pay WHERE id = v_employee_wallet_id;

    COMMIT;
END$$

DELIMITER ;

-- =============================================================================================
-- Not included here — no SQL change needed (Java-only fixes):
--   sp_log_compliance_event — the deployed procedure signature is already correct
--   (p_tenant_id, p_legal_asset_id, p_log_type, p_details, p_new_expiry_date, p_created_by) and
--   has no OUT parameter. The mismatch was entirely on the Java side
--   (ComplianceProcedureRepository omitted p_tenant_id, sent an unknown p_log_date the procedure
--   never declared, and declared a nonexistent OUT log_id) — fixed in
--   ComplianceProcedureRepository.java, no SQL change required.
--
-- Not included here — confirmed obsolete, not wired up (see investigation notes above):
--   sp_renew_employee_contract (ContractService only exposes CREATE/UPDATE/SOFT_DELETE_CONTRACT
--   via plain JPA saves — there is no RENEW_CONTRACT event in ContractEventRequest, so wiring the
--   procedure would mean inventing a new feature, not fixing a broken one) and sp_create_vat_record
--   (VatService.createVat already inserts fact_vat rows directly via JPA; the procedure's
--   duplicate-period SIGNAL guard has no JPA-side equivalent, which is a minor gap but a separate
--   concern from "dead code", not addressed here).
-- =============================================================================================

-- ----- fix_crm_commission_procedure.sql -----
-- =============================================================================================
-- fix_crm_commission_procedure.sql — sp_create_commission_on_deal_close was called from Java
-- (CrmProcedureRepository.closeDeal -> OpportunityService.closeDeal) with a completely different
-- parameter shape than the procedure actually declared: Java sent
-- (p_opportunity_id, p_agent_id, p_outcome, p_changed_by) + expected an OUT p_commission_id,
-- while the procedure was (p_tenant_id, p_opportunity_id, p_agent_id, p_period, p_updated_by)
-- with NO out parameter. Since JDBC binds positionally, Java's opportunity_id landed in the
-- procedure's p_tenant_id slot, and the "outcome" string (e.g. "Closed Won") landed in the
-- p_agent_id slot (INT) — this almost certainly threw a type-conversion error on every call,
-- meaning the CRM "Close Deal" action was broken.
--
-- Fix direction (per product decision): rewrite the procedure to match Java's existing intent
-- (CloseDealPayload only ever sent opportunityId/outcome/agentId — never a "period" — so period
-- is now derived server-side as the current YYYY-MM instead of being a caller-supplied value).
--   - Adds p_tenant_id (first param, tenant-scopes both lookups — closes the audit finding that
--     the original procedure's opportunity/agent lookups had no tenant filter).
--   - Adds SIGNAL SQLSTATE guards: fail cleanly if the opportunity or agent isn't found, instead
--     of silently proceeding with NULL values and inserting a corrupt fact_commissions row.
--   - Renames p_period -> p_outcome: the opportunity's new status is now whatever outcome the
--     caller passed (defaulting to 'Deal_Won' only if blank), instead of being hardcoded.
--   - Adds OUT p_commission_id so the Java caller gets back the ID it already expected.
--
-- Safe to re-run: DROP + CREATE.
-- =============================================================================================

USE `stacie_Aggie_v1.0`;

DROP PROCEDURE IF EXISTS `sp_create_commission_on_deal_close`;

DELIMITER $$

CREATE PROCEDURE `sp_create_commission_on_deal_close`(
    IN p_tenant_id INT,
    IN p_opportunity_id BIGINT,
    IN p_agent_id INT,
    IN p_outcome VARCHAR(50),
    IN p_updated_by INT,
    OUT p_commission_id BIGINT
)
BEGIN
    DECLARE v_deal_value DECIMAL(15,2);
    DECLARE v_old_status VARCHAR(50);
    DECLARE v_agent_name VARCHAR(255);
    DECLARE v_rate DECIMAL(5,2);
    DECLARE v_earned DECIMAL(15,2);
    DECLARE v_new_stage VARCHAR(50);
    DECLARE v_new_status VARCHAR(50);
    DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;

    START TRANSACTION;

    SELECT deal_value, status INTO v_deal_value, v_old_status
    FROM fact_opportunities WHERE id = p_opportunity_id AND tenant_id = p_tenant_id;
    IF v_deal_value IS NULL THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Opportunity not found';
    END IF;

    SELECT CONCAT(first_name, ' ', last_name), commission_rate INTO v_agent_name, v_rate
    FROM dim_sales_agents WHERE id = p_agent_id AND tenant_id = p_tenant_id;
    IF v_agent_name IS NULL THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Sales agent not found';
    END IF;

    -- p_outcome (e.g. "Closed Won"/"Closed Lost") is a fact_opportunities.stage value, NOT a
    -- status value — status is the narrower enum('New','Contacted','Qualified','Lost','Won').
    -- The original procedure hardcoded status='Deal_Won', which was never a valid enum value
    -- either; derive both columns correctly here instead.
    SET v_new_stage = CASE WHEN p_outcome IS NOT NULL AND p_outcome <> '' THEN p_outcome ELSE 'Closed Won' END;
    SET v_new_status = CASE WHEN v_new_stage LIKE '%Lost%' THEN 'Lost' ELSE 'Won' END;
    SET v_earned = v_deal_value * (v_rate / 100);

    UPDATE fact_opportunities SET status = v_new_status, stage = v_new_stage, updated_by = p_updated_by
    WHERE id = p_opportunity_id AND tenant_id = p_tenant_id;

    INSERT INTO fact_opportunity_logs (tenant_id, opportunity_id, old_status, new_status, changed_by)
    VALUES (p_tenant_id, p_opportunity_id, v_old_status, v_new_status, p_updated_by);

    INSERT INTO fact_commissions (
        tenant_id, agent_id, agent_name, period, deal_ref, gross_amount, rate, earned, total, status, created_by
    ) VALUES (
        p_tenant_id, p_agent_id, v_agent_name, DATE_FORMAT(CURDATE(), '%Y-%m'),
        CAST(p_opportunity_id AS CHAR), v_deal_value, v_rate, v_earned, v_earned, 'Pending', p_updated_by
    );

    SET p_commission_id = LAST_INSERT_ID();
    COMMIT;
END$$

DELIMITER ;

-- ----- finance_views.sql -----
-- Finance reporting views (deploy manually)
USE `stacie_Aggie_v1.0`;

CREATE OR REPLACE VIEW vw_wallet_ledger_summary AS
SELECT
    w.tenant_id,
    w.id AS wallet_id,
    w.wallet_name,
    w.balance,
    w.currency,
    es.id AS subcategory_id,
    es.subcategory_name,
    COALESCE(SUM(CASE WHEN le.entry_type = 'Debit' THEN le.amount ELSE 0 END), 0) AS total_debits,
    COALESCE(SUM(CASE WHEN le.entry_type = 'Credit' THEN le.amount ELSE 0 END), 0) AS total_credits
FROM dim_wallets w
LEFT JOIN fact_ledger_entries le ON le.wallet_id = w.id AND le.is_deleted = 0
LEFT JOIN dim_expense_subcategories es ON es.id = le.subcategory_id
WHERE w.is_deleted = 0
GROUP BY w.tenant_id, w.id, w.wallet_name, w.balance, w.currency, es.id, es.subcategory_name;

CREATE OR REPLACE VIEW vw_cash_flow AS
SELECT
    t.tenant_id,
    t.id AS transaction_id,
    t.from_wallet_id,
    fw.wallet_name AS from_wallet_name,
    t.to_wallet_id,
    tw.wallet_name AS to_wallet_name,
    t.amount,
    t.currency,
    t.transaction_date,
    t.status,
    t.reference,
    t.type
FROM fact_transactions t
LEFT JOIN dim_wallets fw ON fw.id = t.from_wallet_id
LEFT JOIN dim_wallets tw ON tw.id = t.to_wallet_id
WHERE t.is_deleted = 0;

-- NOTE: originally written against dim_bank_statements/fact_bank_transactions, which never
-- existed under those names — the real tables are fact_statements (joined to
-- dim_financial_entities for the bank/account label) and fact_statement_transactions, whose
-- cash_in_or_out enum is 'Cash In'/'Cash Out', not 'In'/'Out'. Fixed to match the actual schema.
CREATE OR REPLACE VIEW vw_bank_reconciliation AS
SELECT
    s.tenant_id,
    s.id AS statement_id,
    e.bank_or_issuer_name AS bank_name,
    e.account_number,
    s.beginning_balance,
    s.current_balance,
    COALESCE(SUM(CASE WHEN st.cash_in_or_out = 'Cash In' THEN st.amount ELSE 0 END), 0) AS total_cash_in,
    COALESCE(SUM(CASE WHEN st.cash_in_or_out = 'Cash Out' THEN st.amount ELSE 0 END), 0) AS total_cash_out,
    s.beginning_balance
        + COALESCE(SUM(CASE WHEN st.cash_in_or_out = 'Cash In' THEN st.amount WHEN st.cash_in_or_out = 'Cash Out' THEN -st.amount ELSE 0 END), 0) AS reconciled_balance,
    COUNT(st.id) AS transaction_count
FROM fact_statements s
JOIN dim_financial_entities e ON e.id = s.entity_id AND e.is_deleted = 0
LEFT JOIN fact_statement_transactions st ON st.statement_id = s.id AND st.is_deleted = 0
WHERE s.is_deleted = 0
GROUP BY s.tenant_id, s.id, e.bank_or_issuer_name, e.account_number, s.beginning_balance, s.current_balance;

CREATE OR REPLACE VIEW vw_commission_report AS
SELECT
    c.tenant_id,
    c.id AS commission_id,
    c.agent_id,
    c.agent_name,
    c.period,
    c.deal_ref,
    c.gross_amount,
    c.rate,
    c.earned,
    c.bonus,
    c.total,
    c.currency,
    c.status,
    c.paid_date
FROM fact_commissions c
WHERE c.is_deleted = 0;

-- ----- bank_account_branch_fields_and_banks.sql -----
-- =============================================================================
-- Bank account form: add representative + branch fields to dim_financial_entities,
-- and a dim_banks reference table (seeded with major US banks) for the Bank Name
-- dropdown on /finance/business-accounts/bank-accounts.
--
-- Idempotent: column adds are guarded via information_schema; dim_banks uses
-- CREATE TABLE IF NOT EXISTS + UNIQUE(name) + INSERT IGNORE.
-- Deploy: mysql -u root -p `stacie_Aggie_v1.0` < db/bank_account_branch_fields_and_banks.sql
-- =============================================================================
USE `stacie_Aggie_v1.0`;

-- ── 1. New columns on dim_financial_entities ────────────────────────────────
SET @db := DATABASE();

-- business_id: the JPA entity maps a Business relation and list/create queries filter
-- on it; some older dev databases predate this column. Guarded add so prod (which
-- already has it) is unaffected.
SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE dim_financial_entities ADD COLUMN business_id INT NULL AFTER tenant_id',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_financial_entities' AND COLUMN_NAME='business_id');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE dim_financial_entities ADD COLUMN bank_representative VARCHAR(255) NULL AFTER bank_email',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_financial_entities' AND COLUMN_NAME='bank_representative');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE dim_financial_entities ADD COLUMN bank_branch VARCHAR(255) NULL AFTER bank_representative',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_financial_entities' AND COLUMN_NAME='bank_branch');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE dim_financial_entities ADD COLUMN bank_branch_number VARCHAR(100) NULL AFTER bank_branch',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_financial_entities' AND COLUMN_NAME='bank_branch_number');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := (SELECT IF(COUNT(*)=0,
  'ALTER TABLE dim_financial_entities ADD COLUMN bank_branch_address VARCHAR(500) NULL AFTER bank_branch_number',
  'SELECT 1') FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_financial_entities' AND COLUMN_NAME='bank_branch_address');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- ── 1b. Align entity_type enum with the Java enum names ─────────────────────
-- The JPA enum is stored as its constant name ('Bank','CreditCard'), but some dev
-- DBs define the column as enum('Bank','Credit Card') with a space, which truncates
-- on CreditCard inserts. Expand → migrate legacy value → collapse to canonical.
--
-- entity_type carries no index, so this UPDATE is rejected by MySQL Workbench/
-- phpMyAdmin's client-side "safe update mode" (Error 1175) even though it has a
-- WHERE clause — that mode specifically requires a KEY column in the WHERE.
-- Toggle it off for just this one statement rather than relying on every client's
-- local preference being configured a particular way.
SET SQL_SAFE_UPDATES = 0;
ALTER TABLE dim_financial_entities
  MODIFY COLUMN entity_type ENUM('Bank','Credit Card','CreditCard') NOT NULL;
UPDATE dim_financial_entities SET entity_type='CreditCard' WHERE entity_type='Credit Card';
SET SQL_SAFE_UPDATES = 1;
ALTER TABLE dim_financial_entities
  MODIFY COLUMN entity_type ENUM('Bank','CreditCard') NOT NULL;

-- ── 2. Reference table for the Bank Name dropdown ───────────────────────────
CREATE TABLE IF NOT EXISTS dim_banks (
    id          INT AUTO_INCREMENT PRIMARY KEY,
    name        VARCHAR(255) NOT NULL,
    country     VARCHAR(10)  NOT NULL DEFAULT 'US',
    is_deleted  TINYINT(1)   NOT NULL DEFAULT 0,
    created_at  TIMESTAMP    DEFAULT CURRENT_TIMESTAMP,
    updated_at  TIMESTAMP    DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uq_dim_banks_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 3. Seed major US banks (curated ~100; INSERT IGNORE = safe to re-run) ────
INSERT IGNORE INTO dim_banks (name) VALUES
('JPMorgan Chase Bank'),('Bank of America'),('Wells Fargo Bank'),('Citibank'),
('U.S. Bank'),('PNC Bank'),('Truist Bank'),('Goldman Sachs Bank USA'),
('TD Bank'),('Capital One'),('The Bank of New York Mellon'),('State Street Bank and Trust'),
('Citizens Bank'),('Fifth Third Bank'),('Morgan Stanley Bank'),('HSBC Bank USA'),
('Ally Bank'),('KeyBank'),('Regions Bank'),('M&T Bank'),
('Huntington National Bank'),('American Express National Bank'),('Discover Bank'),('BMO Bank'),
('First Republic Bank'),('Silicon Valley Bank'),('Charles Schwab Bank'),('Comerica Bank'),
('Zions Bank'),('First Citizens Bank'),('Synchrony Bank'),('Santander Bank'),
('Flagstar Bank'),('Western Alliance Bank'),('Valley National Bank'),('Webster Bank'),
('East West Bank'),('First Horizon Bank'),('Frost Bank'),('Bank of Oklahoma'),
('Pinnacle Bank'),('Wintrust Bank'),('Associated Bank'),('Old National Bank'),
('UMB Bank'),('Bank OZK'),('Prosperity Bank'),('Cadence Bank'),
('South State Bank'),('Fulton Bank'),('Simmons Bank'),('Umpqua Bank'),
('Texas Capital Bank'),('Glacier Bank'),('Centennial Bank'),('Commerce Bank'),
('First National Bank of Omaha'),('Arvest Bank'),('WesBanco'),('Renasant Bank'),
('Ameris Bank'),('Bank of Hawaii'),('First Hawaiian Bank'),('Rockland Trust'),
('Banner Bank'),('Cathay Bank'),('Bank of Hope'),('Pacific Western Bank'),
('Trustmark National Bank'),('Independent Bank'),('Atlantic Union Bank'),('TowneBank'),
('Sandy Spring Bank'),('Eastern Bank'),('Berkshire Bank'),('NBT Bank'),
('Provident Bank'),('Dime Community Bank'),('ConnectOne Bank'),('First Interstate Bank'),
('Hancock Whitney Bank'),('BankUnited'),('Axos Bank'),('Live Oak Bank'),
('EverBank'),('USAA Federal Savings Bank'),('Navy Federal Credit Union'),('PenFed Credit Union'),
('Varo Bank'),('SoFi Bank'),('Mercury'),('Brex'),
('City National Bank'),('Signature Bank'),('First National Bank'),('Bremer Bank'),
('Great Southern Bank'),('Columbia Bank'),('Customers Bank'),('Amerant Bank'),
('Beneficial State Bank'),('Nicolet National Bank');

-- ----- user_business_access.sql -----
-- =============================================================================
-- user_business_access: which businesses (under a tenant) a user may access.
-- Replaces per-module READ/WRITE grants for tenant managers/users with a simple
-- per-business on/off grant. SUPER_ADMIN / TENANT_ADMIN are not restricted.
-- Deploy: mysql -u root -p `stacie_Aggie_v1.0` < db/user_business_access.sql
-- =============================================================================
USE `stacie_Aggie_v1.0`;

CREATE TABLE IF NOT EXISTS user_business_access (
    id          BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id   INT NOT NULL,
    user_id     INT NOT NULL,
    business_id INT NOT NULL,
    is_deleted  TINYINT(1) NOT NULL DEFAULT 0,
    created_at  TIMESTAMP  DEFAULT CURRENT_TIMESTAMP,
    created_by  INT NULL,
    updated_at  TIMESTAMP  DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uq_uba_user_business (user_id, business_id),
    KEY idx_uba_user (user_id),
    KEY idx_uba_tenant (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ----- fix_sp_process_ai_bank_statement.sql -----
-- ============================================================================================
-- FIX: sp_process_ai_bank_statement — MariaDB compatibility
-- The target server is MariaDB, whose JSON_VALUE() does not support MySQL 8's
-- "RETURNING <type>" clause (it always returns text). Replaced with CAST(JSON_VALUE(...) AS ...)
-- for the three numeric fields (beginningBalance, currentBalance, interestRate).
-- Safe to re-run: DROP PROCEDURE IF EXISTS + CREATE PROCEDURE.
-- This same fix has also been applied in full_schema_snapshot.sql.
-- ============================================================================================

DELIMITER $$

DROP PROCEDURE IF EXISTS `sp_process_ai_bank_statement`$$
CREATE PROCEDURE `sp_process_ai_bank_statement`(
    IN  p_statement_json     LONGTEXT,
    IN  p_transactions_json  LONGTEXT,
    IN  p_created_by         INT,
    IN  p_entity_type        VARCHAR(50),
    IN  p_entity_id          BIGINT,
    OUT p_statement_id       BIGINT,
    OUT p_transaction_count  INT
)
proc: BEGIN
    DECLARE v_tenant_id     INT;
    DECLARE v_entity_id     BIGINT;
    DECLARE v_entity_type   VARCHAR(20);
    DECLARE v_account_no    VARCHAR(100);
    DECLARE v_bank_name     VARCHAR(255);
    DECLARE v_entity_label  VARCHAR(255);
    DECLARE v_business_id   INT;

    SET v_tenant_id = @current_tenant_id;
    IF v_tenant_id IS NULL THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Tenant context (@current_tenant_id) is required';
    END IF;

    -- Selected business scope (nullable). Persisted on the entity + statement so
    -- business-filtered list screens can find these imported records.
    SET v_business_id = @current_business_id;

    IF p_statement_json IS NULL OR JSON_VALID(p_statement_json) = 0 THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Invalid statement JSON';
    END IF;
    IF p_transactions_json IS NULL OR JSON_VALID(p_transactions_json) = 0 THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Invalid transactions JSON';
    END IF;

    -- Normalise entity type to the dim_financial_entities enum ('Bank' | 'Credit Card')
    SET v_entity_type = CASE
        WHEN p_entity_type IN ('CreditCard', 'Credit Card', 'CREDIT_CARD') THEN 'Credit Card'
        ELSE 'Bank'
    END;

    SET v_account_no = JSON_VALUE(p_statement_json, '$.accountNumber');

    -- ── Ensure the bank exists in the global reference list (dim_banks) ──────
    -- The AI extraction may surface a bank/issuer not yet in the Bank Name
    -- dropdown. Register it so it becomes selectable for future entities.
    -- INSERT IGNORE + UNIQUE(name) makes this an idempotent "add if missing".
    SET v_bank_name = NULLIF(TRIM(JSON_VALUE(p_statement_json, '$.bankName')), '');
    IF v_bank_name IS NOT NULL THEN
        INSERT IGNORE INTO dim_banks (name) VALUES (v_bank_name);
    END IF;

    -- User-supplied account label/nickname (falls back to bank/holder name below).
    SET v_entity_label = NULLIF(TRIM(JSON_VALUE(p_statement_json, '$.entityLabel')), '');

    -- ── Resolve the financial entity ────────────────────────────────────────
    -- 1) explicit entity_id, 2) existing tenant entity by account number, 3) create.
    IF p_entity_id IS NOT NULL THEN
        SELECT id INTO v_entity_id
        FROM dim_financial_entities
        WHERE id = p_entity_id AND tenant_id = v_tenant_id AND is_deleted = 0
        LIMIT 1;
    END IF;

    IF v_entity_id IS NULL AND v_account_no IS NOT NULL AND v_account_no <> '' THEN
        SELECT id INTO v_entity_id
        FROM dim_financial_entities
        WHERE tenant_id = v_tenant_id
          AND account_number = v_account_no
          AND entity_type = v_entity_type
          AND is_deleted = 0
        ORDER BY id LIMIT 1;
    END IF;

    IF v_entity_id IS NULL THEN
        INSERT INTO dim_financial_entities (
            tenant_id, business_id, entity_name, entity_type, account_holder_name, account_number,
            bank_or_issuer_name, currency, status, created_by, updated_by, is_deleted
        ) VALUES (
            v_tenant_id,
            v_business_id,
            COALESCE(
                v_entity_label,
                v_bank_name,
                NULLIF(JSON_VALUE(p_statement_json, '$.accountHolderName'), ''),
                'Imported Entity'),
            v_entity_type,
            JSON_VALUE(p_statement_json, '$.accountHolderName'),
            v_account_no,
            v_bank_name,
            COALESCE(NULLIF(JSON_VALUE(p_statement_json, '$.currency'), ''), 'USD'),
            'Active',
            p_created_by, p_created_by, 0
        );
        SET v_entity_id = LAST_INSERT_ID();
    END IF;

    -- Backfill business scope on a pre-existing entity that was imported without one
    -- (e.g. before business_id propagation existed), so it appears in scoped lists.
    IF v_business_id IS NOT NULL AND v_entity_id IS NOT NULL THEN
        UPDATE dim_financial_entities
        SET business_id = v_business_id
        WHERE id = v_entity_id AND tenant_id = v_tenant_id AND business_id IS NULL;
    END IF;

    -- ── Insert the statement header ─────────────────────────────────────────
    INSERT INTO fact_statements (
        tenant_id, business_id, entity_id, statement_period, beginning_balance, current_balance,
        current_interest_rate, created_by, updated_by, is_deleted
    ) VALUES (
        v_tenant_id,
        v_business_id,
        v_entity_id,
        JSON_VALUE(p_statement_json, '$.statementPeriod'),
        CAST(JSON_VALUE(p_statement_json, '$.beginningBalance') AS DECIMAL(15,2)),
        CAST(JSON_VALUE(p_statement_json, '$.currentBalance') AS DECIMAL(15,2)),
        CAST(JSON_VALUE(p_statement_json, '$.interestRate') AS DECIMAL(5,2)),
        p_created_by, p_created_by, 0
    );

    SET p_statement_id = LAST_INSERT_ID();

    -- ── Insert the transactions ─────────────────────────────────────────────
    INSERT INTO fact_statement_transactions (
        tenant_id, statement_id, transaction_date, transaction_type, description, vendor,
        amount, cash_in_or_out, personal_or_business, associated_card_number, home_tax_percent,
        person_or_project_1, expense_labels, pdf_link, created_by, updated_by, is_deleted
    )
    SELECT
        v_tenant_id,
        p_statement_id,
        jt.transaction_date,
        CASE
            WHEN jt.transaction_type IN ('Credit','Debit','Transfer','Fee','Interest','Reversal')
                THEN jt.transaction_type
            WHEN LOWER(COALESCE(jt.cash_in_or_out, '')) LIKE '%in%' THEN 'Credit'
            ELSE 'Debit'
        END,
        jt.description,
        jt.vendor,
        jt.amount,
        CASE WHEN LOWER(COALESCE(jt.cash_in_or_out, '')) LIKE '%in%' THEN 'Cash In' ELSE 'Cash Out' END,
        CASE WHEN jt.personal_or_business IN ('Personal','Business') THEN jt.personal_or_business ELSE NULL END,
        jt.associated_card_number,
        jt.home_tax_percent,
        jt.person_or_project_1,
        jt.expense_labels,
        jt.pdf_link,
        p_created_by, p_created_by, 0
    FROM JSON_TABLE(
        p_transactions_json,
        '$[*]' COLUMNS (
            transaction_date      DATE           PATH '$.transactionDate',
            transaction_type      VARCHAR(50)    PATH '$.transactionType',
            description           TEXT           PATH '$.description',
            vendor                VARCHAR(255)   PATH '$.vendor',
            amount                DECIMAL(15,2)  PATH '$.amount',
            cash_in_or_out        VARCHAR(20)    PATH '$.cashInOrOut',
            personal_or_business  VARCHAR(20)    PATH '$.personalOrBusiness',
            associated_card_number VARCHAR(100)  PATH '$.associatedCardNumber',
            home_tax_percent      DECIMAL(5,2)   PATH '$.homeTaxPercent',
            person_or_project_1   VARCHAR(255)   PATH '$.personOrProject1',
            expense_labels        TEXT           PATH '$.expenseLabels',
            pdf_link              VARCHAR(512)   PATH '$.pdfLink'
        )
    ) AS jt;

    SET p_transaction_count = ROW_COUNT();
END proc $$

DELIMITER ;

-- ----- attendance_sp_views.sql -----
-- HRM Attendance: SP-write / View-read stack for fact_attendance.
-- Deploy: mysql -u root -p `stacie_Aggie_v1.0` < db/attendance_sp_views.sql

USE `stacie_Aggie_v1.0`;

CREATE OR REPLACE VIEW vw_attendance AS
SELECT
    a.id            AS id,
    a.id            AS attendanceId,
    a.tenant_id     AS tenant_id,
    a.employee_id   AS employeeId,
    a.employee_name AS employeeName,
    a.date          AS date,
    a.status        AS status,
    a.hours_worked  AS hours,
    a.check_in      AS checkIn,
    a.check_out     AS checkOut,
    a.notes         AS notes,
    a.created_at    AS createdAt,
    a.updated_at    AS updatedAt
FROM fact_attendance a
WHERE a.is_deleted = 0;

DROP PROCEDURE IF EXISTS sp_create_attendance;
DROP PROCEDURE IF EXISTS sp_update_attendance;
DROP PROCEDURE IF EXISTS sp_soft_delete_attendance;

DELIMITER $$

CREATE PROCEDURE sp_create_attendance(
    IN  p_tenant_id     INT,
    IN  p_employee_id   INT,
    IN  p_employee_name VARCHAR(255),
    IN  p_date          DATE,
    IN  p_status        VARCHAR(50),
    IN  p_hours         DECIMAL(5,2),
    IN  p_check_in      VARCHAR(20),
    IN  p_check_out     VARCHAR(20),
    IN  p_notes         TEXT,
    IN  p_created_by    INT,
    OUT p_attendance_id BIGINT
)
proc: BEGIN
    DECLARE v_name VARCHAR(255);
    IF p_tenant_id IS NULL THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Tenant id is required';
    END IF;
    IF p_date IS NULL THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Attendance date is required';
    END IF;

    SET v_name = NULLIF(TRIM(COALESCE(p_employee_name, '')), '');
    IF v_name IS NULL AND p_employee_id IS NOT NULL THEN
        SELECT NULLIF(TRIM(CONCAT(COALESCE(first_name, ''), ' ', COALESCE(last_name, ''))), '')
          INTO v_name
          FROM dim_employees
         WHERE id = p_employee_id AND tenant_id = p_tenant_id
         LIMIT 1;
    END IF;
    SET v_name = COALESCE(v_name, 'Unknown');

    INSERT INTO fact_attendance (
        tenant_id, employee_id, employee_name, date, status, hours_worked,
        check_in, check_out, notes, created_at, created_by, updated_at, updated_by, is_deleted
    ) VALUES (
        p_tenant_id, p_employee_id, v_name, p_date,
        COALESCE(p_status, 'Present'), p_hours,
        NULLIF(p_check_in, ''), NULLIF(p_check_out, ''), p_notes,
        NOW(), p_created_by, NOW(), p_created_by, 0
    );
    SET p_attendance_id = LAST_INSERT_ID();
END$$

CREATE PROCEDURE sp_update_attendance(
    IN  p_tenant_id     INT,
    IN  p_attendance_id BIGINT,
    IN  p_employee_id   INT,
    IN  p_employee_name VARCHAR(255),
    IN  p_date          DATE,
    IN  p_status        VARCHAR(50),
    IN  p_hours         DECIMAL(5,2),
    IN  p_check_in      VARCHAR(20),
    IN  p_check_out     VARCHAR(20),
    IN  p_notes         TEXT,
    IN  p_updated_by    INT
)
proc: BEGIN
    DECLARE v_exists INT;
    SELECT COUNT(*) INTO v_exists FROM fact_attendance
        WHERE id = p_attendance_id AND tenant_id = p_tenant_id AND is_deleted = 0;
    IF v_exists = 0 THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Attendance record not found';
    END IF;

    UPDATE fact_attendance SET
        employee_id   = COALESCE(p_employee_id, employee_id),
        employee_name = COALESCE(NULLIF(TRIM(COALESCE(p_employee_name, '')), ''), employee_name),
        date          = COALESCE(p_date, date),
        status        = COALESCE(p_status, status),
        hours_worked  = COALESCE(p_hours, hours_worked),
        check_in      = COALESCE(NULLIF(p_check_in, ''), check_in),
        check_out     = COALESCE(NULLIF(p_check_out, ''), check_out),
        notes         = COALESCE(p_notes, notes),
        updated_at    = NOW(),
        updated_by    = p_updated_by
    WHERE id = p_attendance_id AND tenant_id = p_tenant_id AND is_deleted = 0;
END$$

CREATE PROCEDURE sp_soft_delete_attendance(
    IN  p_tenant_id     INT,
    IN  p_attendance_id BIGINT,
    IN  p_deleted_by    INT
)
proc: BEGIN
    UPDATE fact_attendance SET
        is_deleted = 1, updated_at = NOW(), updated_by = p_deleted_by
    WHERE id = p_attendance_id AND tenant_id = p_tenant_id AND is_deleted = 0;
END$$

DELIMITER ;

-- ----- payroll_sp_views.sql -----
-- HRM Payroll: SP-write / View-read stack for fact_payroll.
-- Deploy: mysql -u root -p `stacie_Aggie_v1.0` < db/payroll_sp_views.sql

USE `stacie_Aggie_v1.0`;

CREATE OR REPLACE VIEW vw_payroll AS
SELECT
    p.id            AS id,
    p.id            AS payrollId,
    p.tenant_id     AS tenant_id,
    p.employee_id   AS employeeId,
    p.employee_name AS employeeName,
    p.month         AS month,
    p.basic_salary  AS basicSalary,
    p.allowances    AS allowances,
    p.deductions    AS deductions,
    p.tax           AS tax,
    p.net_pay       AS netPay,
    p.currency      AS currency,
    p.status        AS status,
    p.pay_date      AS payDate,
    p.created_at    AS createdAt,
    p.updated_at    AS updatedAt
FROM fact_payroll p
WHERE p.is_deleted = 0;

DROP PROCEDURE IF EXISTS sp_create_payroll;
DROP PROCEDURE IF EXISTS sp_update_payroll;
DROP PROCEDURE IF EXISTS sp_soft_delete_payroll;

DELIMITER $$

CREATE PROCEDURE sp_create_payroll(
    IN  p_tenant_id     INT,
    IN  p_employee_id   INT,
    IN  p_employee_name VARCHAR(255),
    IN  p_month         VARCHAR(20),
    IN  p_basic_salary  DECIMAL(15,2),
    IN  p_allowances    DECIMAL(15,2),
    IN  p_deductions    DECIMAL(15,2),
    IN  p_tax           DECIMAL(15,2),
    IN  p_net_pay       DECIMAL(15,2),
    IN  p_currency      VARCHAR(10),
    IN  p_status        VARCHAR(20),
    IN  p_pay_date      DATE,
    IN  p_created_by    INT,
    OUT p_payroll_id    BIGINT
)
proc: BEGIN
    DECLARE v_name VARCHAR(255);
    IF p_tenant_id IS NULL THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Tenant id is required';
    END IF;
    IF p_basic_salary IS NULL THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Basic salary is required';
    END IF;
    IF p_net_pay IS NULL THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Net pay is required';
    END IF;

    SET v_name = NULLIF(TRIM(COALESCE(p_employee_name, '')), '');
    IF v_name IS NULL AND p_employee_id IS NOT NULL THEN
        SELECT NULLIF(TRIM(CONCAT(COALESCE(first_name, ''), ' ', COALESCE(last_name, ''))), '')
          INTO v_name
          FROM dim_employees
         WHERE id = p_employee_id AND tenant_id = p_tenant_id
         LIMIT 1;
    END IF;
    SET v_name = COALESCE(v_name, 'Unknown');

    INSERT INTO fact_payroll (
        tenant_id, employee_id, employee_name, month, basic_salary, allowances,
        deductions, tax, net_pay, currency, status, pay_date,
        created_at, created_by, updated_at, updated_by, is_deleted
    ) VALUES (
        p_tenant_id, p_employee_id, v_name, p_month, p_basic_salary, COALESCE(p_allowances, 0),
        COALESCE(p_deductions, 0), COALESCE(p_tax, 0), p_net_pay, COALESCE(p_currency, 'USD'),
        COALESCE(p_status, 'Draft'), p_pay_date,
        NOW(), p_created_by, NOW(), p_created_by, 0
    );
    SET p_payroll_id = LAST_INSERT_ID();
END$$

CREATE PROCEDURE sp_update_payroll(
    IN  p_tenant_id     INT,
    IN  p_payroll_id    BIGINT,
    IN  p_employee_id   INT,
    IN  p_employee_name VARCHAR(255),
    IN  p_month         VARCHAR(20),
    IN  p_basic_salary  DECIMAL(15,2),
    IN  p_allowances    DECIMAL(15,2),
    IN  p_deductions    DECIMAL(15,2),
    IN  p_tax           DECIMAL(15,2),
    IN  p_net_pay       DECIMAL(15,2),
    IN  p_currency      VARCHAR(10),
    IN  p_status        VARCHAR(20),
    IN  p_pay_date      DATE,
    IN  p_updated_by    INT
)
proc: BEGIN
    DECLARE v_exists INT;
    SELECT COUNT(*) INTO v_exists FROM fact_payroll
        WHERE id = p_payroll_id AND tenant_id = p_tenant_id AND is_deleted = 0;
    IF v_exists = 0 THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Payroll record not found';
    END IF;

    UPDATE fact_payroll SET
        employee_id   = COALESCE(p_employee_id, employee_id),
        employee_name = COALESCE(NULLIF(TRIM(COALESCE(p_employee_name, '')), ''), employee_name),
        month         = COALESCE(p_month, month),
        basic_salary  = COALESCE(p_basic_salary, basic_salary),
        allowances    = COALESCE(p_allowances, allowances),
        deductions    = COALESCE(p_deductions, deductions),
        tax           = COALESCE(p_tax, tax),
        net_pay       = COALESCE(p_net_pay, net_pay),
        currency      = COALESCE(p_currency, currency),
        status        = COALESCE(p_status, status),
        pay_date      = COALESCE(p_pay_date, pay_date),
        updated_at    = NOW(),
        updated_by    = p_updated_by
    WHERE id = p_payroll_id AND tenant_id = p_tenant_id AND is_deleted = 0;
END$$

CREATE PROCEDURE sp_soft_delete_payroll(
    IN  p_tenant_id     INT,
    IN  p_payroll_id    BIGINT,
    IN  p_deleted_by    INT
)
proc: BEGIN
    UPDATE fact_payroll SET
        is_deleted = 1, updated_at = NOW(), updated_by = p_deleted_by
    WHERE id = p_payroll_id AND tenant_id = p_tenant_id AND is_deleted = 0;
END$$

DELIMITER ;

-- ----- digital_assets_sp_views.sql -----
-- Inventory Digital Assets: purpose-fit table + SP-write / View-read stack.
-- The legacy dim_assets (asset_tag/serial_number/asset_type_id, Available/Allocated status)
-- models physical assets and does not match the frontend DigitalAsset shape (licenses/domains
-- with vendor, cost, expiry, assignee). We introduce inv_digital_assets for the UI contract.
-- Deploy: mysql -u root -p `stacie_Aggie_v1.0` < db/digital_assets_sp_views.sql

USE `stacie_Aggie_v1.0`;

CREATE TABLE IF NOT EXISTS inv_digital_assets (
    id          INT AUTO_INCREMENT PRIMARY KEY,
    tenant_id   INT NOT NULL,
    name        VARCHAR(255) NOT NULL,
    category    VARCHAR(150) NULL,
    vendor      VARCHAR(255) NULL,
    cost        DECIMAL(15,2) NULL,
    currency    VARCHAR(10) NULL DEFAULT 'USD',
    status      VARCHAR(50) NULL DEFAULT 'Active',
    expiry_date DATE NULL,
    assigned_to VARCHAR(255) NULL,
    created_at  TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
    created_by  INT NULL,
    updated_at  TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
    updated_by  INT NULL,
    is_deleted  TINYINT(1) NULL DEFAULT 0,
    KEY idx_inv_digital_assets_tenant (tenant_id, is_deleted)
);

CREATE OR REPLACE VIEW vw_digital_assets AS
SELECT
    d.id          AS id,
    d.id          AS assetId,
    d.tenant_id   AS tenant_id,
    d.name        AS name,
    d.category    AS category,
    d.vendor      AS vendor,
    d.cost        AS cost,
    d.currency    AS currency,
    d.status      AS status,
    d.expiry_date AS expiryDate,
    d.assigned_to AS assignedTo,
    d.created_at  AS createdAt,
    d.updated_at  AS updatedAt
FROM inv_digital_assets d
WHERE d.is_deleted = 0;

DROP PROCEDURE IF EXISTS sp_create_digital_asset;
DROP PROCEDURE IF EXISTS sp_update_digital_asset;
DROP PROCEDURE IF EXISTS sp_soft_delete_digital_asset;

DELIMITER $$

CREATE PROCEDURE sp_create_digital_asset(
    IN  p_tenant_id   INT,
    IN  p_name        VARCHAR(255),
    IN  p_category    VARCHAR(150),
    IN  p_vendor      VARCHAR(255),
    IN  p_cost        DECIMAL(15,2),
    IN  p_currency    VARCHAR(10),
    IN  p_status      VARCHAR(50),
    IN  p_expiry_date DATE,
    IN  p_assigned_to VARCHAR(255),
    IN  p_created_by  INT,
    OUT p_asset_id    INT
)
proc: BEGIN
    IF p_tenant_id IS NULL THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Tenant id is required';
    END IF;
    IF p_name IS NULL OR p_name = '' THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Asset name is required';
    END IF;

    INSERT INTO inv_digital_assets (
        tenant_id, name, category, vendor, cost, currency, status, expiry_date, assigned_to,
        created_at, created_by, updated_at, updated_by, is_deleted
    ) VALUES (
        p_tenant_id, p_name, p_category, p_vendor, p_cost, COALESCE(p_currency, 'USD'),
        COALESCE(p_status, 'Active'), p_expiry_date, p_assigned_to,
        NOW(), p_created_by, NOW(), p_created_by, 0
    );
    SET p_asset_id = LAST_INSERT_ID();
END$$

CREATE PROCEDURE sp_update_digital_asset(
    IN  p_tenant_id   INT,
    IN  p_asset_id    INT,
    IN  p_name        VARCHAR(255),
    IN  p_category    VARCHAR(150),
    IN  p_vendor      VARCHAR(255),
    IN  p_cost        DECIMAL(15,2),
    IN  p_currency    VARCHAR(10),
    IN  p_status      VARCHAR(50),
    IN  p_expiry_date DATE,
    IN  p_assigned_to VARCHAR(255),
    IN  p_updated_by  INT
)
proc: BEGIN
    DECLARE v_exists INT;
    SELECT COUNT(*) INTO v_exists FROM inv_digital_assets
        WHERE id = p_asset_id AND tenant_id = p_tenant_id AND is_deleted = 0;
    IF v_exists = 0 THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Digital asset not found';
    END IF;

    UPDATE inv_digital_assets SET
        name        = COALESCE(p_name, name),
        category    = COALESCE(p_category, category),
        vendor      = COALESCE(p_vendor, vendor),
        cost        = COALESCE(p_cost, cost),
        currency    = COALESCE(p_currency, currency),
        status      = COALESCE(p_status, status),
        expiry_date = COALESCE(p_expiry_date, expiry_date),
        assigned_to = COALESCE(p_assigned_to, assigned_to),
        updated_at  = NOW(),
        updated_by  = p_updated_by
    WHERE id = p_asset_id AND tenant_id = p_tenant_id AND is_deleted = 0;
END$$

CREATE PROCEDURE sp_soft_delete_digital_asset(
    IN  p_tenant_id  INT,
    IN  p_asset_id   INT,
    IN  p_deleted_by INT
)
proc: BEGIN
    UPDATE inv_digital_assets SET
        is_deleted = 1, updated_at = NOW(), updated_by = p_deleted_by
    WHERE id = p_asset_id AND tenant_id = p_tenant_id AND is_deleted = 0;
END$$

DELIMITER ;

-- ----- loans_sp_views.sql -----
-- HRM Loans: SP-write / View-read stack for fact_loans.
-- Deploy: mysql -u root -p `stacie_Aggie_v1.0` < db/loans_sp_views.sql

USE `stacie_Aggie_v1.0`;

CREATE OR REPLACE VIEW vw_loans AS
SELECT
    l.id                 AS id,
    l.id                 AS loanId,
    l.tenant_id          AS tenant_id,
    l.employee_id        AS employeeId,
    l.employee_name      AS employeeName,
    l.loan_amount        AS loanAmount,
    l.currency           AS currency,
    l.no_of_installments AS installments,
    l.monthly_amount     AS monthlyAmount,
    l.start_date         AS startDate,
    l.status             AS status,
    l.purpose            AS purpose,
    l.notes              AS notes,
    l.created_at         AS createdAt,
    l.updated_at         AS updatedAt
FROM fact_loans l
WHERE l.is_deleted = 0;

DROP PROCEDURE IF EXISTS sp_create_loan;
DROP PROCEDURE IF EXISTS sp_update_loan;
DROP PROCEDURE IF EXISTS sp_soft_delete_loan;

DELIMITER $$

CREATE PROCEDURE sp_create_loan(
    IN  p_tenant_id     INT,
    IN  p_employee_id   INT,
    IN  p_employee_name VARCHAR(255),
    IN  p_loan_amount   DECIMAL(15,2),
    IN  p_currency      VARCHAR(10),
    IN  p_installments  INT,
    IN  p_monthly       DECIMAL(15,2),
    IN  p_start_date    DATE,
    IN  p_status        VARCHAR(50),
    IN  p_purpose       TEXT,
    IN  p_notes         TEXT,
    IN  p_created_by    INT,
    OUT p_loan_id       BIGINT
)
proc: BEGIN
    DECLARE v_name VARCHAR(255);
    DECLARE v_installments INT;
    DECLARE v_monthly DECIMAL(15,2);
    IF p_tenant_id IS NULL THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Tenant id is required';
    END IF;
    IF p_loan_amount IS NULL THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Loan amount is required';
    END IF;

    SET v_name = NULLIF(TRIM(COALESCE(p_employee_name, '')), '');
    IF v_name IS NULL AND p_employee_id IS NOT NULL THEN
        SELECT NULLIF(TRIM(CONCAT(COALESCE(first_name, ''), ' ', COALESCE(last_name, ''))), '')
          INTO v_name FROM dim_employees
         WHERE id = p_employee_id AND tenant_id = p_tenant_id LIMIT 1;
    END IF;
    SET v_name = COALESCE(v_name, 'Unknown');

    SET v_installments = COALESCE(NULLIF(p_installments, 0), 1);
    SET v_monthly = COALESCE(p_monthly, ROUND(p_loan_amount / v_installments, 2));

    INSERT INTO fact_loans (
        tenant_id, employee_id, employee_name, loan_amount, currency, no_of_installments,
        monthly_amount, start_date, status, purpose, notes,
        created_at, created_by, updated_at, updated_by, is_deleted
    ) VALUES (
        p_tenant_id, p_employee_id, v_name, p_loan_amount, COALESCE(p_currency, 'USD'), v_installments,
        v_monthly, p_start_date, COALESCE(p_status, 'Pending'), p_purpose, p_notes,
        NOW(), p_created_by, NOW(), p_created_by, 0
    );
    SET p_loan_id = LAST_INSERT_ID();
END$$

CREATE PROCEDURE sp_update_loan(
    IN  p_tenant_id     INT,
    IN  p_loan_id       BIGINT,
    IN  p_employee_id   INT,
    IN  p_employee_name VARCHAR(255),
    IN  p_loan_amount   DECIMAL(15,2),
    IN  p_currency      VARCHAR(10),
    IN  p_installments  INT,
    IN  p_monthly       DECIMAL(15,2),
    IN  p_start_date    DATE,
    IN  p_status        VARCHAR(50),
    IN  p_purpose       TEXT,
    IN  p_notes         TEXT,
    IN  p_updated_by    INT
)
proc: BEGIN
    DECLARE v_exists INT;
    SELECT COUNT(*) INTO v_exists FROM fact_loans
        WHERE id = p_loan_id AND tenant_id = p_tenant_id AND is_deleted = 0;
    IF v_exists = 0 THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Loan not found';
    END IF;

    UPDATE fact_loans SET
        employee_id        = COALESCE(p_employee_id, employee_id),
        employee_name      = COALESCE(NULLIF(TRIM(COALESCE(p_employee_name, '')), ''), employee_name),
        loan_amount        = COALESCE(p_loan_amount, loan_amount),
        currency           = COALESCE(p_currency, currency),
        no_of_installments = COALESCE(NULLIF(p_installments, 0), no_of_installments),
        monthly_amount     = COALESCE(p_monthly, monthly_amount),
        start_date         = COALESCE(p_start_date, start_date),
        status             = COALESCE(p_status, status),
        purpose            = COALESCE(p_purpose, purpose),
        notes              = COALESCE(p_notes, notes),
        updated_at         = NOW(),
        updated_by         = p_updated_by
    WHERE id = p_loan_id AND tenant_id = p_tenant_id AND is_deleted = 0;
END$$

CREATE PROCEDURE sp_soft_delete_loan(
    IN  p_tenant_id  INT,
    IN  p_loan_id    BIGINT,
    IN  p_deleted_by INT
)
proc: BEGIN
    UPDATE fact_loans SET
        is_deleted = 1, updated_at = NOW(), updated_by = p_deleted_by
    WHERE id = p_loan_id AND tenant_id = p_tenant_id AND is_deleted = 0;
END$$

DELIMITER ;

-- ----- orders_sp_views.sql -----
-- CRM Orders: SP-write / View-read stack for fact_orders.
-- Deploy: mysql -u root -p `stacie_Aggie_v1.0` < db/orders_sp_views.sql

USE `stacie_Aggie_v1.0`;

-- ---------------------------------------------------------------------------
-- Read model: view exposes camelCase aliases matching the frontend orderFromApi
-- ---------------------------------------------------------------------------
CREATE OR REPLACE VIEW vw_orders AS
SELECT
    o.id                        AS id,
    o.id                        AS orderId,
    o.tenant_id                 AS tenant_id,
    o.order_no                  AS orderNo,
    o.client_name               AS clientName,
    o.status                    AS status,
    o.total_amount              AS totalAmount,
    o.total_amount              AS total,
    o.currency                  AS currency,
    o.no_of_items               AS items,
    o.order_date                AS orderDate,
    o.delivery_date             AS deliveryDate,
    o.notes                     AS notes,
    o.created_at                AS createdAt,
    o.updated_at                AS updatedAt
FROM fact_orders o
WHERE o.is_deleted = 0;

-- ---------------------------------------------------------------------------
-- Write model: create / update / soft-delete stored procedures
-- ---------------------------------------------------------------------------
DROP PROCEDURE IF EXISTS sp_create_order;
DROP PROCEDURE IF EXISTS sp_update_order;
DROP PROCEDURE IF EXISTS sp_soft_delete_order;

DELIMITER $$

CREATE PROCEDURE sp_create_order(
    IN  p_tenant_id     INT,
    IN  p_order_no      VARCHAR(100),
    IN  p_client_name   VARCHAR(255),
    IN  p_status        VARCHAR(50),
    IN  p_total_amount  DECIMAL(15,2),
    IN  p_currency      VARCHAR(10),
    IN  p_no_of_items   INT,
    IN  p_order_date    DATE,
    IN  p_delivery_date DATE,
    IN  p_notes         TEXT,
    IN  p_created_by    INT,
    OUT p_order_id      BIGINT
)
proc: BEGIN
    IF p_tenant_id IS NULL THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Tenant id is required';
    END IF;
    IF p_order_no IS NULL OR p_order_no = '' THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Order number is required';
    END IF;
    IF p_client_name IS NULL OR p_client_name = '' THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Client name is required';
    END IF;

    INSERT INTO fact_orders (
        tenant_id, order_no, client_name, status, total_amount, currency,
        no_of_items, order_date, delivery_date, notes,
        created_at, created_by, updated_at, updated_by, is_deleted
    ) VALUES (
        p_tenant_id, p_order_no, p_client_name,
        COALESCE(p_status, 'Pending'),
        COALESCE(p_total_amount, 0),
        COALESCE(p_currency, 'USD'),
        COALESCE(p_no_of_items, 0),
        p_order_date, p_delivery_date, p_notes,
        NOW(), p_created_by, NOW(), p_created_by, 0
    );

    SET p_order_id = LAST_INSERT_ID();
END$$

CREATE PROCEDURE sp_update_order(
    IN  p_tenant_id     INT,
    IN  p_order_id      BIGINT,
    IN  p_order_no      VARCHAR(100),
    IN  p_client_name   VARCHAR(255),
    IN  p_status        VARCHAR(50),
    IN  p_total_amount  DECIMAL(15,2),
    IN  p_currency      VARCHAR(10),
    IN  p_no_of_items   INT,
    IN  p_order_date    DATE,
    IN  p_delivery_date DATE,
    IN  p_notes         TEXT,
    IN  p_updated_by    INT
)
proc: BEGIN
    DECLARE v_exists INT;
    SELECT COUNT(*) INTO v_exists FROM fact_orders
        WHERE id = p_order_id AND tenant_id = p_tenant_id AND is_deleted = 0;
    IF v_exists = 0 THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Order not found';
    END IF;

    UPDATE fact_orders SET
        order_no      = COALESCE(p_order_no, order_no),
        client_name   = COALESCE(p_client_name, client_name),
        status        = COALESCE(p_status, status),
        total_amount  = COALESCE(p_total_amount, total_amount),
        currency      = COALESCE(p_currency, currency),
        no_of_items   = COALESCE(p_no_of_items, no_of_items),
        order_date    = COALESCE(p_order_date, order_date),
        delivery_date = COALESCE(p_delivery_date, delivery_date),
        notes         = COALESCE(p_notes, notes),
        updated_at    = NOW(),
        updated_by    = p_updated_by
    WHERE id = p_order_id AND tenant_id = p_tenant_id AND is_deleted = 0;
END$$

CREATE PROCEDURE sp_soft_delete_order(
    IN  p_tenant_id  INT,
    IN  p_order_id   BIGINT,
    IN  p_deleted_by INT
)
proc: BEGIN
    UPDATE fact_orders SET
        is_deleted = 1,
        updated_at = NOW(),
        updated_by = p_deleted_by
    WHERE id = p_order_id AND tenant_id = p_tenant_id AND is_deleted = 0;
END$$

DELIMITER ;

-- ----- products_sp_views.sql -----
-- Inventory Products: purpose-fit table + SP-write / View-read stack.
-- The legacy dim_products (category_id FK, base_cost/base_retail) does not match the
-- frontend Product shape and is unused, so we introduce inv_products keyed to the UI contract.
-- Deploy: mysql -u root -p `stacie_Aggie_v1.0` < db/products_sp_views.sql

USE `stacie_Aggie_v1.0`;

CREATE TABLE IF NOT EXISTS inv_products (
    id            INT AUTO_INCREMENT PRIMARY KEY,
    tenant_id     INT NOT NULL,
    name          VARCHAR(255) NOT NULL,
    sku           VARCHAR(100) NULL,
    category      VARCHAR(150) NULL,
    unit_price    DECIMAL(15,2) NOT NULL DEFAULT 0,
    cost_price    DECIMAL(15,2) NULL,
    currency      VARCHAR(10) NULL DEFAULT 'USD',
    stock_qty     INT NULL DEFAULT 0,
    reorder_level INT NULL DEFAULT 0,
    unit          VARCHAR(50) NULL,
    status        ENUM('Active','Inactive','Discontinued') NULL DEFAULT 'Active',
    created_at    TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
    created_by    INT NULL,
    updated_at    TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
    updated_by    INT NULL,
    is_deleted    TINYINT(1) NULL DEFAULT 0,
    KEY idx_inv_products_tenant (tenant_id, is_deleted)
);

CREATE OR REPLACE VIEW vw_products AS
SELECT
    p.id            AS id,
    p.id            AS productId,
    p.tenant_id     AS tenant_id,
    p.name          AS name,
    p.sku           AS sku,
    p.category      AS category,
    p.unit_price    AS unitPrice,
    p.cost_price    AS costPrice,
    p.currency      AS currency,
    p.stock_qty     AS stockQty,
    p.reorder_level AS reorderLevel,
    p.unit          AS unit,
    p.status        AS status,
    p.created_at    AS createdAt,
    p.updated_at    AS updatedAt
FROM inv_products p
WHERE p.is_deleted = 0;

DROP PROCEDURE IF EXISTS sp_create_product;
DROP PROCEDURE IF EXISTS sp_update_product;
DROP PROCEDURE IF EXISTS sp_soft_delete_product;

DELIMITER $$

CREATE PROCEDURE sp_create_product(
    IN  p_tenant_id     INT,
    IN  p_name          VARCHAR(255),
    IN  p_sku           VARCHAR(100),
    IN  p_category      VARCHAR(150),
    IN  p_unit_price    DECIMAL(15,2),
    IN  p_cost_price    DECIMAL(15,2),
    IN  p_currency      VARCHAR(10),
    IN  p_stock_qty     INT,
    IN  p_reorder_level INT,
    IN  p_unit          VARCHAR(50),
    IN  p_status        VARCHAR(50),
    IN  p_created_by    INT,
    OUT p_product_id    INT
)
proc: BEGIN
    IF p_tenant_id IS NULL THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Tenant id is required';
    END IF;
    IF p_name IS NULL OR p_name = '' THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Product name is required';
    END IF;

    INSERT INTO inv_products (
        tenant_id, name, sku, category, unit_price, cost_price, currency,
        stock_qty, reorder_level, unit, status,
        created_at, created_by, updated_at, updated_by, is_deleted
    ) VALUES (
        p_tenant_id, p_name, p_sku, p_category, COALESCE(p_unit_price, 0), p_cost_price,
        COALESCE(p_currency, 'USD'), COALESCE(p_stock_qty, 0), COALESCE(p_reorder_level, 0),
        p_unit, COALESCE(p_status, 'Active'),
        NOW(), p_created_by, NOW(), p_created_by, 0
    );
    SET p_product_id = LAST_INSERT_ID();
END$$

CREATE PROCEDURE sp_update_product(
    IN  p_tenant_id     INT,
    IN  p_product_id    INT,
    IN  p_name          VARCHAR(255),
    IN  p_sku           VARCHAR(100),
    IN  p_category      VARCHAR(150),
    IN  p_unit_price    DECIMAL(15,2),
    IN  p_cost_price    DECIMAL(15,2),
    IN  p_currency      VARCHAR(10),
    IN  p_stock_qty     INT,
    IN  p_reorder_level INT,
    IN  p_unit          VARCHAR(50),
    IN  p_status        VARCHAR(50),
    IN  p_updated_by    INT
)
proc: BEGIN
    DECLARE v_exists INT;
    SELECT COUNT(*) INTO v_exists FROM inv_products
        WHERE id = p_product_id AND tenant_id = p_tenant_id AND is_deleted = 0;
    IF v_exists = 0 THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Product not found';
    END IF;

    UPDATE inv_products SET
        name          = COALESCE(p_name, name),
        sku           = COALESCE(p_sku, sku),
        category      = COALESCE(p_category, category),
        unit_price    = COALESCE(p_unit_price, unit_price),
        cost_price    = COALESCE(p_cost_price, cost_price),
        currency      = COALESCE(p_currency, currency),
        stock_qty     = COALESCE(p_stock_qty, stock_qty),
        reorder_level = COALESCE(p_reorder_level, reorder_level),
        unit          = COALESCE(p_unit, unit),
        status        = COALESCE(p_status, status),
        updated_at    = NOW(),
        updated_by    = p_updated_by
    WHERE id = p_product_id AND tenant_id = p_tenant_id AND is_deleted = 0;
END$$

CREATE PROCEDURE sp_soft_delete_product(
    IN  p_tenant_id  INT,
    IN  p_product_id INT,
    IN  p_deleted_by INT
)
proc: BEGIN
    UPDATE inv_products SET
        is_deleted = 1, updated_at = NOW(), updated_by = p_deleted_by
    WHERE id = p_product_id AND tenant_id = p_tenant_id AND is_deleted = 0;
END$$

DELIMITER ;

-- ----- purchases_sp_views.sql -----
-- Procurement Purchases: SP-write / View-read stack for fact_purchases.
-- Deploy: mysql -u root -p `stacie_Aggie_v1.0` < db/purchases_sp_views.sql

USE `stacie_Aggie_v1.0`;

-- Add notes column (frontend sends purchase notes) if not present.
-- Uses DATABASE() rather than a hardcoded schema name — a literal '`stacie_Aggie_v1.0`' here
-- silently never matches on any other target database (dev renamed, staging, production),
-- so the guard always reports "column missing" and re-attempts the ADD COLUMN, which then
-- fails with "Duplicate column name" once it already exists. DATABASE() is what every other
-- guarded ALTER in this codebase already uses.
SET @col_exists = (SELECT COUNT(*) FROM information_schema.columns
                   WHERE table_schema = DATABASE() AND table_name = 'fact_purchases' AND column_name = 'notes');
SET @ddl = IF(@col_exists = 0, 'ALTER TABLE fact_purchases ADD COLUMN notes TEXT NULL AFTER purchase_date', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;

CREATE OR REPLACE VIEW vw_purchases AS
SELECT
    p.id                     AS id,
    p.id                     AS purchaseId,
    p.tenant_id              AS tenant_id,
    p.supplier_id            AS supplierId,
    s.vendor_name            AS supplierName,
    s.vendor_name            AS vendorName,
    p.wallet_id              AS walletId,
    w.wallet_name            AS walletName,
    p.amount                 AS amount,
    p.is_capital_expenditure AS isCapitalExpenditure,
    p.purchase_date          AS purchaseDate,
    p.notes                  AS notes,
    p.created_at             AS createdAt,
    p.updated_at             AS updatedAt
FROM fact_purchases p
LEFT JOIN dim_suppliers s ON s.id = p.supplier_id
LEFT JOIN dim_wallets   w ON w.id = p.wallet_id
WHERE p.is_deleted = 0;

DROP PROCEDURE IF EXISTS sp_create_purchase;
DROP PROCEDURE IF EXISTS sp_update_purchase;
DROP PROCEDURE IF EXISTS sp_soft_delete_purchase;

DELIMITER $$

CREATE PROCEDURE sp_create_purchase(
    IN  p_tenant_id     INT,
    IN  p_supplier_id   INT,
    IN  p_wallet_id     INT,
    IN  p_is_capex      TINYINT,
    IN  p_amount        DECIMAL(15,2),
    IN  p_purchase_date DATE,
    IN  p_notes         TEXT,
    IN  p_created_by    INT,
    OUT p_purchase_id   BIGINT
)
proc: BEGIN
    IF p_tenant_id IS NULL THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Tenant id is required';
    END IF;
    IF p_supplier_id IS NULL THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Supplier is required';
    END IF;
    IF p_wallet_id IS NULL THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Wallet is required';
    END IF;

    INSERT INTO fact_purchases (
        tenant_id, supplier_id, wallet_id, is_capital_expenditure, amount,
        purchase_date, notes, created_at, created_by, updated_at, updated_by, is_deleted
    ) VALUES (
        p_tenant_id, p_supplier_id, p_wallet_id, COALESCE(p_is_capex, 0), COALESCE(p_amount, 0),
        p_purchase_date, p_notes, NOW(), p_created_by, NOW(), p_created_by, 0
    );
    SET p_purchase_id = LAST_INSERT_ID();
END$$

CREATE PROCEDURE sp_update_purchase(
    IN  p_tenant_id     INT,
    IN  p_purchase_id   BIGINT,
    IN  p_supplier_id   INT,
    IN  p_wallet_id     INT,
    IN  p_is_capex      TINYINT,
    IN  p_amount        DECIMAL(15,2),
    IN  p_purchase_date DATE,
    IN  p_notes         TEXT,
    IN  p_updated_by    INT
)
proc: BEGIN
    DECLARE v_exists INT;
    SELECT COUNT(*) INTO v_exists FROM fact_purchases
        WHERE id = p_purchase_id AND tenant_id = p_tenant_id AND is_deleted = 0;
    IF v_exists = 0 THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Purchase not found';
    END IF;

    UPDATE fact_purchases SET
        supplier_id            = COALESCE(p_supplier_id, supplier_id),
        wallet_id              = COALESCE(p_wallet_id, wallet_id),
        is_capital_expenditure = COALESCE(p_is_capex, is_capital_expenditure),
        amount                 = COALESCE(p_amount, amount),
        purchase_date          = COALESCE(p_purchase_date, purchase_date),
        notes                  = COALESCE(p_notes, notes),
        updated_at             = NOW(),
        updated_by             = p_updated_by
    WHERE id = p_purchase_id AND tenant_id = p_tenant_id AND is_deleted = 0;
END$$

CREATE PROCEDURE sp_soft_delete_purchase(
    IN  p_tenant_id   INT,
    IN  p_purchase_id BIGINT,
    IN  p_deleted_by  INT
)
proc: BEGIN
    UPDATE fact_purchases SET
        is_deleted = 1, updated_at = NOW(), updated_by = p_deleted_by
    WHERE id = p_purchase_id AND tenant_id = p_tenant_id AND is_deleted = 0;
END$$

DELIMITER ;

-- ----- suppliers_sp_views.sql -----
-- Inventory/Procurement Suppliers: SP-write / View-read stack for dim_suppliers.
-- Deploy: mysql -u root -p `stacie_Aggie_v1.0` < db/suppliers_sp_views.sql

USE `stacie_Aggie_v1.0`;

CREATE OR REPLACE VIEW vw_suppliers AS
SELECT
    s.id          AS id,
    s.id          AS supplierId,
    s.tenant_id   AS tenant_id,
    s.vendor_name AS vendorName,
    s.vendor_name AS name,
    s.category    AS category,
    s.tax_id      AS taxId,
    s.created_at  AS createdAt,
    s.updated_at  AS updatedAt
FROM dim_suppliers s
WHERE s.is_deleted = 0;

DROP PROCEDURE IF EXISTS sp_create_supplier;
DROP PROCEDURE IF EXISTS sp_update_supplier;
DROP PROCEDURE IF EXISTS sp_soft_delete_supplier;

DELIMITER $$

CREATE PROCEDURE sp_create_supplier(
    IN  p_tenant_id   INT,
    IN  p_vendor_name VARCHAR(255),
    IN  p_category    VARCHAR(100),
    IN  p_tax_id      VARCHAR(50),
    IN  p_created_by  INT,
    OUT p_supplier_id INT
)
proc: BEGIN
    IF p_tenant_id IS NULL THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Tenant id is required';
    END IF;
    IF p_vendor_name IS NULL OR p_vendor_name = '' THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Vendor name is required';
    END IF;

    INSERT INTO dim_suppliers (
        tenant_id, vendor_name, category, tax_id,
        created_at, created_by, updated_at, updated_by, is_deleted
    ) VALUES (
        p_tenant_id, p_vendor_name, p_category, p_tax_id,
        NOW(), p_created_by, NOW(), p_created_by, 0
    );
    SET p_supplier_id = LAST_INSERT_ID();
END$$

CREATE PROCEDURE sp_update_supplier(
    IN  p_tenant_id   INT,
    IN  p_supplier_id INT,
    IN  p_vendor_name VARCHAR(255),
    IN  p_category    VARCHAR(100),
    IN  p_tax_id      VARCHAR(50),
    IN  p_updated_by  INT
)
proc: BEGIN
    DECLARE v_exists INT;
    SELECT COUNT(*) INTO v_exists FROM dim_suppliers
        WHERE id = p_supplier_id AND tenant_id = p_tenant_id AND is_deleted = 0;
    IF v_exists = 0 THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Supplier not found';
    END IF;

    UPDATE dim_suppliers SET
        vendor_name = COALESCE(p_vendor_name, vendor_name),
        category    = COALESCE(p_category, category),
        tax_id      = COALESCE(p_tax_id, tax_id),
        updated_at  = NOW(),
        updated_by  = p_updated_by
    WHERE id = p_supplier_id AND tenant_id = p_tenant_id AND is_deleted = 0;
END$$

CREATE PROCEDURE sp_soft_delete_supplier(
    IN  p_tenant_id   INT,
    IN  p_supplier_id INT,
    IN  p_deleted_by  INT
)
proc: BEGIN
    UPDATE dim_suppliers SET
        is_deleted = 1, updated_at = NOW(), updated_by = p_deleted_by
    WHERE id = p_supplier_id AND tenant_id = p_tenant_id AND is_deleted = 0;
END$$

DELIMITER ;

-- ----- orphan_domains_sp_views.sql -----
-- Orphan-stub domains (no prior schema/UI): Materials, Liabilities, Sprints, Resources.
-- New purpose-designed tables + SP-write / View-read stacks. Schemas are our design
-- (no existing contract) and are expected to be reviewed/adjusted by the product owner.
-- Deploy: mysql -u root -p `stacie_Aggie_v1.0` < db/orphan_domains_sp_views.sql

USE `stacie_Aggie_v1.0`;

-- =========================================================================
-- 1) Inventory Materials  (/api/inventory/materials)
-- =========================================================================
CREATE TABLE IF NOT EXISTS inv_materials (
    id            INT AUTO_INCREMENT PRIMARY KEY,
    tenant_id     INT NOT NULL,
    name          VARCHAR(255) NOT NULL,
    code          VARCHAR(100) NULL,
    category      VARCHAR(150) NULL,
    unit          VARCHAR(50) NULL,
    stock_qty     DECIMAL(15,2) NULL DEFAULT 0,
    unit_cost     DECIMAL(15,2) NULL,
    currency      VARCHAR(10) NULL DEFAULT 'USD',
    reorder_level DECIMAL(15,2) NULL DEFAULT 0,
    status        ENUM('Active','Inactive','Discontinued') NULL DEFAULT 'Active',
    notes         TEXT NULL,
    created_at    TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
    created_by    INT NULL,
    updated_at    TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
    updated_by    INT NULL,
    is_deleted    TINYINT(1) NULL DEFAULT 0,
    KEY idx_inv_materials_tenant (tenant_id, is_deleted)
);

CREATE OR REPLACE VIEW vw_materials AS
SELECT id AS id, id AS materialId, tenant_id, name, code, category, unit,
       stock_qty AS stockQty, unit_cost AS unitCost, currency, reorder_level AS reorderLevel,
       status, notes, created_at AS createdAt, updated_at AS updatedAt
FROM inv_materials WHERE is_deleted = 0;

-- =========================================================================
-- 2) Finance Liabilities  (/api/inventory/liabilities  — stub path retained)
-- =========================================================================
CREATE TABLE IF NOT EXISTS fin_liabilities (
    id           INT AUTO_INCREMENT PRIMARY KEY,
    tenant_id    INT NOT NULL,
    name         VARCHAR(255) NOT NULL,
    type         VARCHAR(50) NULL,
    counterparty VARCHAR(255) NULL,
    principal    DECIMAL(15,2) NOT NULL DEFAULT 0,
    outstanding  DECIMAL(15,2) NULL,
    currency     VARCHAR(10) NULL DEFAULT 'USD',
    interest_rate DECIMAL(6,3) NULL,
    start_date   DATE NULL,
    due_date     DATE NULL,
    status       ENUM('Active','Settled','Defaulted') NULL DEFAULT 'Active',
    notes        TEXT NULL,
    created_at   TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
    created_by   INT NULL,
    updated_at   TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
    updated_by   INT NULL,
    is_deleted   TINYINT(1) NULL DEFAULT 0,
    KEY idx_fin_liabilities_tenant (tenant_id, is_deleted)
);

CREATE OR REPLACE VIEW vw_liabilities AS
SELECT id AS id, id AS liabilityId, tenant_id, name, type, counterparty,
       principal, COALESCE(outstanding, principal) AS outstanding, currency,
       interest_rate AS interestRate, start_date AS startDate, due_date AS dueDate,
       status, notes, created_at AS createdAt, updated_at AS updatedAt
FROM fin_liabilities WHERE is_deleted = 0;

-- =========================================================================
-- 3) Project Sprints  (/api/projects/sprints)
-- =========================================================================
CREATE TABLE IF NOT EXISTS proj_sprints (
    id         INT AUTO_INCREMENT PRIMARY KEY,
    tenant_id  INT NOT NULL,
    name       VARCHAR(255) NOT NULL,
    project_id INT NULL,
    goal       TEXT NULL,
    start_date DATE NULL,
    end_date   DATE NULL,
    status     ENUM('Planning','Active','Completed','Cancelled') NULL DEFAULT 'Planning',
    capacity   INT NULL,
    created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
    created_by INT NULL,
    updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
    updated_by INT NULL,
    is_deleted TINYINT(1) NULL DEFAULT 0,
    KEY idx_proj_sprints_tenant (tenant_id, is_deleted)
);

CREATE OR REPLACE VIEW vw_sprints AS
SELECT s.id AS id, s.id AS sprintId, s.tenant_id, s.name, s.project_id AS projectId,
       p.name AS projectName, s.goal, s.start_date AS startDate, s.end_date AS endDate,
       s.status, s.capacity, s.created_at AS createdAt, s.updated_at AS updatedAt
FROM proj_sprints s
LEFT JOIN dim_projects p ON p.id = s.project_id
WHERE s.is_deleted = 0;

-- =========================================================================
-- 4) Project Resources  (/api/projects/resources)
-- =========================================================================
CREATE TABLE IF NOT EXISTS proj_resources (
    id            INT AUTO_INCREMENT PRIMARY KEY,
    tenant_id     INT NOT NULL,
    name          VARCHAR(255) NOT NULL,
    type          VARCHAR(50) NULL,
    project_id    INT NULL,
    role          VARCHAR(150) NULL,
    allocation_pct INT NULL,
    cost_rate     DECIMAL(15,2) NULL,
    currency      VARCHAR(10) NULL DEFAULT 'USD',
    availability  ENUM('Available','Allocated','Unavailable') NULL DEFAULT 'Available',
    notes         TEXT NULL,
    created_at    TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
    created_by    INT NULL,
    updated_at    TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
    updated_by    INT NULL,
    is_deleted    TINYINT(1) NULL DEFAULT 0,
    KEY idx_proj_resources_tenant (tenant_id, is_deleted)
);

CREATE OR REPLACE VIEW vw_resources AS
SELECT r.id AS id, r.id AS resourceId, r.tenant_id, r.name, r.type, r.project_id AS projectId,
       p.name AS projectName, r.role, r.allocation_pct AS allocationPct,
       r.cost_rate AS costRate, r.currency, r.availability,
       r.notes, r.created_at AS createdAt, r.updated_at AS updatedAt
FROM proj_resources r
LEFT JOIN dim_projects p ON p.id = r.project_id
WHERE r.is_deleted = 0;

DROP PROCEDURE IF EXISTS sp_create_material;
DROP PROCEDURE IF EXISTS sp_update_material;
DROP PROCEDURE IF EXISTS sp_soft_delete_material;
DROP PROCEDURE IF EXISTS sp_create_liability;
DROP PROCEDURE IF EXISTS sp_update_liability;
DROP PROCEDURE IF EXISTS sp_soft_delete_liability;
DROP PROCEDURE IF EXISTS sp_create_sprint;
DROP PROCEDURE IF EXISTS sp_update_sprint;
DROP PROCEDURE IF EXISTS sp_soft_delete_sprint;
DROP PROCEDURE IF EXISTS sp_create_resource;
DROP PROCEDURE IF EXISTS sp_update_resource;
DROP PROCEDURE IF EXISTS sp_soft_delete_resource;

DELIMITER $$

-- ---- Materials ----
CREATE PROCEDURE sp_create_material(
    IN p_tenant_id INT, IN p_name VARCHAR(255), IN p_code VARCHAR(100), IN p_category VARCHAR(150),
    IN p_unit VARCHAR(50), IN p_stock_qty DECIMAL(15,2), IN p_unit_cost DECIMAL(15,2), IN p_currency VARCHAR(10),
    IN p_reorder_level DECIMAL(15,2), IN p_status VARCHAR(50), IN p_notes TEXT, IN p_created_by INT, OUT p_material_id INT)
proc: BEGIN
    IF p_name IS NULL OR p_name = '' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Material name is required'; END IF;
    INSERT INTO inv_materials (tenant_id,name,code,category,unit,stock_qty,unit_cost,currency,reorder_level,status,notes,created_at,created_by,updated_at,updated_by,is_deleted)
    VALUES (p_tenant_id,p_name,p_code,p_category,p_unit,COALESCE(p_stock_qty,0),p_unit_cost,COALESCE(p_currency,'USD'),COALESCE(p_reorder_level,0),COALESCE(p_status,'Active'),p_notes,NOW(),p_created_by,NOW(),p_created_by,0);
    SET p_material_id = LAST_INSERT_ID();
END$$
CREATE PROCEDURE sp_update_material(
    IN p_tenant_id INT, IN p_material_id INT, IN p_name VARCHAR(255), IN p_code VARCHAR(100), IN p_category VARCHAR(150),
    IN p_unit VARCHAR(50), IN p_stock_qty DECIMAL(15,2), IN p_unit_cost DECIMAL(15,2), IN p_currency VARCHAR(10),
    IN p_reorder_level DECIMAL(15,2), IN p_status VARCHAR(50), IN p_notes TEXT, IN p_updated_by INT)
proc: BEGIN
    UPDATE inv_materials SET name=COALESCE(p_name,name),code=COALESCE(p_code,code),category=COALESCE(p_category,category),
        unit=COALESCE(p_unit,unit),stock_qty=COALESCE(p_stock_qty,stock_qty),unit_cost=COALESCE(p_unit_cost,unit_cost),
        currency=COALESCE(p_currency,currency),reorder_level=COALESCE(p_reorder_level,reorder_level),status=COALESCE(p_status,status),
        notes=COALESCE(p_notes,notes),updated_at=NOW(),updated_by=p_updated_by
    WHERE id=p_material_id AND tenant_id=p_tenant_id AND is_deleted=0;
END$$
CREATE PROCEDURE sp_soft_delete_material(IN p_tenant_id INT, IN p_material_id INT, IN p_deleted_by INT)
proc: BEGIN
    UPDATE inv_materials SET is_deleted=1,updated_at=NOW(),updated_by=p_deleted_by WHERE id=p_material_id AND tenant_id=p_tenant_id AND is_deleted=0;
END$$

-- ---- Liabilities ----
CREATE PROCEDURE sp_create_liability(
    IN p_tenant_id INT, IN p_name VARCHAR(255), IN p_type VARCHAR(50), IN p_counterparty VARCHAR(255),
    IN p_principal DECIMAL(15,2), IN p_outstanding DECIMAL(15,2), IN p_currency VARCHAR(10), IN p_interest_rate DECIMAL(6,3),
    IN p_start_date DATE, IN p_due_date DATE, IN p_status VARCHAR(50), IN p_notes TEXT, IN p_created_by INT, OUT p_liability_id INT)
proc: BEGIN
    IF p_name IS NULL OR p_name = '' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Liability name is required'; END IF;
    INSERT INTO fin_liabilities (tenant_id,name,type,counterparty,principal,outstanding,currency,interest_rate,start_date,due_date,status,notes,created_at,created_by,updated_at,updated_by,is_deleted)
    VALUES (p_tenant_id,p_name,p_type,p_counterparty,COALESCE(p_principal,0),COALESCE(p_outstanding,p_principal),COALESCE(p_currency,'USD'),p_interest_rate,p_start_date,p_due_date,COALESCE(p_status,'Active'),p_notes,NOW(),p_created_by,NOW(),p_created_by,0);
    SET p_liability_id = LAST_INSERT_ID();
END$$
CREATE PROCEDURE sp_update_liability(
    IN p_tenant_id INT, IN p_liability_id INT, IN p_name VARCHAR(255), IN p_type VARCHAR(50), IN p_counterparty VARCHAR(255),
    IN p_principal DECIMAL(15,2), IN p_outstanding DECIMAL(15,2), IN p_currency VARCHAR(10), IN p_interest_rate DECIMAL(6,3),
    IN p_start_date DATE, IN p_due_date DATE, IN p_status VARCHAR(50), IN p_notes TEXT, IN p_updated_by INT)
proc: BEGIN
    UPDATE fin_liabilities SET name=COALESCE(p_name,name),type=COALESCE(p_type,type),counterparty=COALESCE(p_counterparty,counterparty),
        principal=COALESCE(p_principal,principal),outstanding=COALESCE(p_outstanding,outstanding),currency=COALESCE(p_currency,currency),
        interest_rate=COALESCE(p_interest_rate,interest_rate),start_date=COALESCE(p_start_date,start_date),due_date=COALESCE(p_due_date,due_date),
        status=COALESCE(p_status,status),notes=COALESCE(p_notes,notes),updated_at=NOW(),updated_by=p_updated_by
    WHERE id=p_liability_id AND tenant_id=p_tenant_id AND is_deleted=0;
END$$
CREATE PROCEDURE sp_soft_delete_liability(IN p_tenant_id INT, IN p_liability_id INT, IN p_deleted_by INT)
proc: BEGIN
    UPDATE fin_liabilities SET is_deleted=1,updated_at=NOW(),updated_by=p_deleted_by WHERE id=p_liability_id AND tenant_id=p_tenant_id AND is_deleted=0;
END$$

-- ---- Sprints ----
CREATE PROCEDURE sp_create_sprint(
    IN p_tenant_id INT, IN p_name VARCHAR(255), IN p_project_id INT, IN p_goal TEXT,
    IN p_start_date DATE, IN p_end_date DATE, IN p_status VARCHAR(50), IN p_capacity INT, IN p_created_by INT, OUT p_sprint_id INT)
proc: BEGIN
    IF p_name IS NULL OR p_name = '' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Sprint name is required'; END IF;
    INSERT INTO proj_sprints (tenant_id,name,project_id,goal,start_date,end_date,status,capacity,created_at,created_by,updated_at,updated_by,is_deleted)
    VALUES (p_tenant_id,p_name,p_project_id,p_goal,p_start_date,p_end_date,COALESCE(p_status,'Planning'),p_capacity,NOW(),p_created_by,NOW(),p_created_by,0);
    SET p_sprint_id = LAST_INSERT_ID();
END$$
CREATE PROCEDURE sp_update_sprint(
    IN p_tenant_id INT, IN p_sprint_id INT, IN p_name VARCHAR(255), IN p_project_id INT, IN p_goal TEXT,
    IN p_start_date DATE, IN p_end_date DATE, IN p_status VARCHAR(50), IN p_capacity INT, IN p_updated_by INT)
proc: BEGIN
    UPDATE proj_sprints SET name=COALESCE(p_name,name),project_id=COALESCE(p_project_id,project_id),goal=COALESCE(p_goal,goal),
        start_date=COALESCE(p_start_date,start_date),end_date=COALESCE(p_end_date,end_date),status=COALESCE(p_status,status),
        capacity=COALESCE(p_capacity,capacity),updated_at=NOW(),updated_by=p_updated_by
    WHERE id=p_sprint_id AND tenant_id=p_tenant_id AND is_deleted=0;
END$$
CREATE PROCEDURE sp_soft_delete_sprint(IN p_tenant_id INT, IN p_sprint_id INT, IN p_deleted_by INT)
proc: BEGIN
    UPDATE proj_sprints SET is_deleted=1,updated_at=NOW(),updated_by=p_deleted_by WHERE id=p_sprint_id AND tenant_id=p_tenant_id AND is_deleted=0;
END$$

-- ---- Resources ----
CREATE PROCEDURE sp_create_resource(
    IN p_tenant_id INT, IN p_name VARCHAR(255), IN p_type VARCHAR(50), IN p_project_id INT, IN p_role VARCHAR(150),
    IN p_allocation_pct INT, IN p_cost_rate DECIMAL(15,2), IN p_currency VARCHAR(10), IN p_availability VARCHAR(50),
    IN p_notes TEXT, IN p_created_by INT, OUT p_resource_id INT)
proc: BEGIN
    IF p_name IS NULL OR p_name = '' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Resource name is required'; END IF;
    INSERT INTO proj_resources (tenant_id,name,type,project_id,role,allocation_pct,cost_rate,currency,availability,notes,created_at,created_by,updated_at,updated_by,is_deleted)
    VALUES (p_tenant_id,p_name,p_type,p_project_id,p_role,p_allocation_pct,p_cost_rate,COALESCE(p_currency,'USD'),COALESCE(p_availability,'Available'),p_notes,NOW(),p_created_by,NOW(),p_created_by,0);
    SET p_resource_id = LAST_INSERT_ID();
END$$
CREATE PROCEDURE sp_update_resource(
    IN p_tenant_id INT, IN p_resource_id INT, IN p_name VARCHAR(255), IN p_type VARCHAR(50), IN p_project_id INT, IN p_role VARCHAR(150),
    IN p_allocation_pct INT, IN p_cost_rate DECIMAL(15,2), IN p_currency VARCHAR(10), IN p_availability VARCHAR(50),
    IN p_notes TEXT, IN p_updated_by INT)
proc: BEGIN
    UPDATE proj_resources SET name=COALESCE(p_name,name),type=COALESCE(p_type,type),project_id=COALESCE(p_project_id,project_id),
        role=COALESCE(p_role,role),allocation_pct=COALESCE(p_allocation_pct,allocation_pct),cost_rate=COALESCE(p_cost_rate,cost_rate),
        currency=COALESCE(p_currency,currency),availability=COALESCE(p_availability,availability),notes=COALESCE(p_notes,notes),
        updated_at=NOW(),updated_by=p_updated_by
    WHERE id=p_resource_id AND tenant_id=p_tenant_id AND is_deleted=0;
END$$
CREATE PROCEDURE sp_soft_delete_resource(IN p_tenant_id INT, IN p_resource_id INT, IN p_deleted_by INT)
proc: BEGIN
    UPDATE proj_resources SET is_deleted=1,updated_at=NOW(),updated_by=p_deleted_by WHERE id=p_resource_id AND tenant_id=p_tenant_id AND is_deleted=0;
END$$

DELIMITER ;

-- ----- fix_opportunity_stage.sql -----
USE `stacie_Aggie_v1.0`;

-- Fixes sp_create_opportunity to persist fact_opportunities.stage (2026-07-28).
-- fact_opportunities already has a `stage` enum('Discovery','Proposal','Negotiation',
-- 'Closed Won','Closed Lost') column distinct from `status` enum('New','Contacted','Qualified',
-- 'Lost','Won') — but the stored procedure never accepted or persisted it, so the Deals UI's
-- pipeline stage was silently dropped on create (and the frontend was additionally sending the
-- stage value into `status`, which fails validation since the two enums don't overlap).
-- Safe to re-run: DROP PROCEDURE IF EXISTS + CREATE.

DROP PROCEDURE IF EXISTS sp_create_opportunity;

DELIMITER $$

CREATE PROCEDURE sp_create_opportunity(
    IN p_tenant_id INT,
    IN p_company_id INT,
    IN p_contact_id INT,
    IN p_title VARCHAR(255),
    IN p_status VARCHAR(50),
    IN p_stage VARCHAR(50),
    IN p_source VARCHAR(100),
    IN p_probability INT,
    IN p_deal_value DECIMAL(15,2),
    IN p_currency VARCHAR(10),
    IN p_assigned_to INT,
    IN p_expected_close_date DATE,
    IN p_notes TEXT,
    IN p_created_by INT,
    OUT p_opportunity_id BIGINT
)
BEGIN
    DECLARE v_full_name VARCHAR(255);
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;

    START TRANSACTION;

    SET v_full_name = p_title;
    IF p_contact_id IS NOT NULL THEN
        SELECT CONCAT(first_name, ' ', last_name) INTO v_full_name
        FROM dim_contacts
        WHERE id = p_contact_id AND tenant_id = p_tenant_id;
        IF v_full_name IS NULL THEN
            SET v_full_name = p_title;
        END IF;
    END IF;

    INSERT INTO fact_opportunities (
        tenant_id, company_id, contact_id, full_name, title, deal_title, status, stage, source,
        probability, deal_value, currency, assigned_to, expected_close_date, notes, created_by
    ) VALUES (
        p_tenant_id, p_company_id, p_contact_id, v_full_name, p_title, p_title, p_status, p_stage, p_source,
        p_probability, p_deal_value, p_currency, p_assigned_to, p_expected_close_date, p_notes, p_created_by
    );

    SET p_opportunity_id = LAST_INSERT_ID();
    COMMIT;
END$$

DELIMITER ;

-- ----- fix_opportunity_company_less_deals.sql -----
USE `stacie_Aggie_v1.0`;

-- =============================================================================================
-- fix_opportunity_company_less_deals.sql — sp_create_opportunity never accepted a way to
-- snapshot who a deal is for when there's no CRM company/contact record (e.g. a web-store
-- order or a website service request). fact_opportunities already has client_name_text/
-- email/phone columns for exactly this ("company-less deal") case, but no INSERT ever
-- populated them. Adds three new IN params and writes them straight through; also widens
-- v_full_name's fallback so a company-less deal gets a real display name instead of just
-- the title. company_id/contact_id were already nullable here — no change needed to those.
--
-- Safe to re-run: DROP + CREATE.
-- =============================================================================================

DROP PROCEDURE IF EXISTS `sp_create_opportunity`;

DELIMITER $$

CREATE PROCEDURE `sp_create_opportunity`(
    IN p_tenant_id INT,
    IN p_company_id INT,
    IN p_contact_id INT,
    IN p_title VARCHAR(255),
    IN p_status VARCHAR(50),
    IN p_stage VARCHAR(50),
    IN p_source VARCHAR(100),
    IN p_probability INT,
    IN p_deal_value DECIMAL(15,2),
    IN p_currency VARCHAR(10),
    IN p_assigned_to INT,
    IN p_expected_close_date DATE,
    IN p_notes TEXT,
    IN p_client_name_text VARCHAR(255),
    IN p_email VARCHAR(255),
    IN p_phone VARCHAR(50),
    IN p_created_by INT,
    OUT p_opportunity_id BIGINT
)
BEGIN
    DECLARE v_full_name VARCHAR(255);
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;

    START TRANSACTION;

    SET v_full_name = COALESCE(p_client_name_text, p_title);
    IF p_contact_id IS NOT NULL THEN
        SELECT CONCAT(first_name, ' ', last_name) INTO v_full_name
        FROM dim_contacts
        WHERE id = p_contact_id AND tenant_id = p_tenant_id;
        IF v_full_name IS NULL THEN
            SET v_full_name = COALESCE(p_client_name_text, p_title);
        END IF;
    END IF;

    INSERT INTO fact_opportunities (
        tenant_id, company_id, contact_id, full_name, title, deal_title, status, stage, source,
        probability, deal_value, currency, assigned_to, expected_close_date, notes,
        client_name_text, email, phone, created_by
    ) VALUES (
        p_tenant_id, p_company_id, p_contact_id, v_full_name, p_title, p_title, p_status, p_stage, p_source,
        p_probability, p_deal_value, p_currency, p_assigned_to, p_expected_close_date, p_notes,
        p_client_name_text, p_email, p_phone, p_created_by
    );

    SET p_opportunity_id = LAST_INSERT_ID();
    COMMIT;
END$$

DELIMITER ;

-- ----- deal_templates_module.sql -----
USE `stacie_Aggie_v1.0`;

-- =============================================================================================
-- deal_templates_module.sql — Web-to-CRM deal automation, Phase D/E groundwork.
--
-- dim_deal_templates lets a tenant configure how a system-initiated web event (a paid web-store
-- order, a website service-request form submission) turns into a fact_opportunities row, without
-- hardcoding stage/status/title vocabulary in Java. Each row is looked up by (tenant_id,
-- template_key) at deal-creation time — see CrmDealFromWebService.
--
-- fact_web_orders.opportunity_id / fact_web_form_submissions.opportunity_id are the back-links
-- from the web-side row to the deal CrmDealFromWebService created for it. fact_web_orders also
-- gets invoice_id now (nullable, unused until Phase E wires invoice creation) so that column
-- doesn't need a second migration later.
--
-- Safe to re-run: CREATE TABLE IF NOT EXISTS, guarded ALTERs, WHERE NOT EXISTS seed guards.
-- =============================================================================================

SET @db := DATABASE();

-- ── 1. dim_deal_templates ────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS dim_deal_templates (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    template_key VARCHAR(100) NOT NULL,
    name VARCHAR(255) NOT NULL,
    default_stage VARCHAR(50) NOT NULL,
    default_status VARCHAR(50) NOT NULL,
    title_pattern VARCHAR(255) NOT NULL,
    line_item_type ENUM('Product','Service') NOT NULL,
    fulfillment_type ENUM('SHIPMENT','PROJECT','NONE') NOT NULL DEFAULT 'NONE',
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_dim_deal_templates_key (tenant_id, template_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 2. fact_web_orders — back-links to the deal/invoice it produced ─────────────────────────
SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_web_orders' AND COLUMN_NAME='opportunity_id'),
  'ALTER TABLE fact_web_orders ADD COLUMN opportunity_id BIGINT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- Unused until Phase E wires invoice creation from a paid web order — the column is added now
-- so that phase doesn't need its own migration just to add one nullable FK.
SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_web_orders' AND COLUMN_NAME='invoice_id'),
  'ALTER TABLE fact_web_orders ADD COLUMN invoice_id BIGINT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- ── 3. fact_web_form_submissions — back-link to the deal it produced ───────────────────────
SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_web_form_submissions' AND COLUMN_NAME='opportunity_id'),
  'ALTER TABLE fact_web_form_submissions ADD COLUMN opportunity_id BIGINT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- ── 4. Seed default templates for tenant 1 (dev tenant convention — see
-- seed_larcare_business_and_site.sql's @tenant_id := 1) ────────────────────────────────────
SET @tenant_id := 1;

INSERT INTO dim_deal_templates (
    tenant_id, business_id, template_key, name, default_stage, default_status,
    title_pattern, line_item_type, fulfillment_type, created_by, updated_by, is_deleted
)
SELECT @tenant_id, NULL, 'web_store_order', 'Web Store Order', 'Negotiation', 'Qualified',
       'Store Order #{orderNo}', 'Product', 'SHIPMENT', NULL, NULL, 0
WHERE NOT EXISTS (
    SELECT 1 FROM dim_deal_templates WHERE tenant_id = @tenant_id AND template_key = 'web_store_order'
);

INSERT INTO dim_deal_templates (
    tenant_id, business_id, template_key, name, default_stage, default_status,
    title_pattern, line_item_type, fulfillment_type, created_by, updated_by, is_deleted
)
SELECT @tenant_id, NULL, 'web_service_request', 'Website Service Request', 'Discovery', 'New',
       'Service Request from {name}', 'Service', 'PROJECT', NULL, NULL, 0
WHERE NOT EXISTS (
    SELECT 1 FROM dim_deal_templates WHERE tenant_id = @tenant_id AND template_key = 'web_service_request'
);

-- ----- fix_invoice_optional_client.sql -----
USE `stacie_Aggie_v1.0`;

-- =============================================================================================
-- fix_invoice_optional_client.sql — lets a fact_invoices row exist without a client/wallet,
-- so a paid web-store order can get a record-only CRM invoice.
--
-- Background: fact_invoices.client_id and .wallet_id are genuinely NOT NULL at the DB level
-- (unlike fact_opportunities.company_id, which was already made optional for company-less
-- deals — see fix_opportunity_company_less_deals.sql). The normal CRM invoice flow
-- (sp_create_invoice_with_ledger / sp_pay_invoice, still used unchanged by InvoiceService for
-- that flow) always has a real DimCompany client and a real DimWallet to bill against, so those
-- columns stayed required until now.
--
-- A web-store order is different: real money already moved client -> Stripe -> the tenant's
-- corporate wallet inside sp_pay_web_order (see web_store_checkout_procedures.sql), which
-- already wrote its own fact_transactions row + fact_ledger_entries Credit row + dim_wallets
-- balance update under reference_entity='WebOrder'. The invoice CrmDealFromWebService creates
-- for that order is a record-only artifact for the CRM/Invoices UI — it must NOT move any
-- ledger money a second time, and a storefront customer is a DimWebCustomer/guest, not
-- necessarily a DimCompany row, so client_id has nothing sensible to point at. Hence both
-- columns need to become nullable, and sp_record_paid_web_order_invoice (below) only ever
-- performs a single plain INSERT with no fact_transactions/fact_ledger_entries/dim_wallets
-- writes at all.
--
-- fact_invoices.opportunity_id is already nullable (added in an earlier migration) — no change
-- needed for that column.
--
-- Safe to re-run: MODIFY COLUMN ... NULL on an already-nullable column is a no-op on both
-- MySQL and MariaDB (unlike ADD COLUMN, there's no "already exists" error to guard against), and
-- DROP PROCEDURE IF EXISTS precedes the CREATE PROCEDURE below.
-- =============================================================================================

ALTER TABLE fact_invoices
    MODIFY COLUMN client_id INT NULL,
    MODIFY COLUMN wallet_id INT NULL;

-- ── sp_record_paid_web_order_invoice — record-only invoice insert for a paid web order ────────
-- Deliberately does NOT touch fact_transactions, fact_ledger_entries, or dim_wallets.balance:
-- the money for this order already moved inside sp_pay_web_order. This procedure only inserts
-- a fact_invoices row (status 'Paid' from creation) so the order shows up in the existing
-- Invoices UI/API — calling sp_create_invoice_with_ledger/sp_pay_invoice here would each
-- independently move ledger money again and double-count against the wallet.
DROP PROCEDURE IF EXISTS `sp_record_paid_web_order_invoice`;

DELIMITER $$

CREATE PROCEDURE `sp_record_paid_web_order_invoice`(
    IN p_tenant_id INT,
    IN p_business_id INT,
    IN p_web_order_id BIGINT,
    IN p_opportunity_id BIGINT,
    IN p_client_name VARCHAR(255),
    IN p_amount DECIMAL(15,2),
    IN p_tax_amount DECIMAL(15,2),
    IN p_currency VARCHAR(10),
    IN p_invoice_no VARCHAR(100),
    IN p_created_by INT,
    OUT p_invoice_id BIGINT
)
BEGIN
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;

    START TRANSACTION;

    INSERT INTO fact_invoices (
        tenant_id, business_id, client_id, wallet_id, client_name, status, amount, tax_amount,
        currency, invoice_no, opportunity_id, issue_date, due_date, created_by
    ) VALUES (
        p_tenant_id, p_business_id, NULL, NULL, p_client_name, 'Paid', p_amount, p_tax_amount,
        p_currency, p_invoice_no, p_opportunity_id, CURDATE(), CURDATE(), p_created_by
    );

    SET p_invoice_id = LAST_INSERT_ID();

    COMMIT;
END$$

DELIMITER ;

-- ----- project_deal_link.sql -----
USE `stacie_Aggie_v1.0`;

-- =============================================================================================
-- project_deal_link.sql — back-link from a fulfillment project to the deal that spawned it.
--
-- CrmDealFromWebService.createProjectForServiceDeal creates a starter dim_projects row when a
-- service-request web deal's dim_deal_templates row has fulfillment_type='PROJECT' (see
-- deal_templates_module.sql's 'web_service_request' seed row). dim_projects.opportunity_id is
-- the back-link from that project to the fact_opportunities row it was created for — mirrors
-- fact_web_orders.opportunity_id / fact_web_form_submissions.opportunity_id's existing
-- convention for this exact kind of back-link (see deal_templates_module.sql). A 'SHIPMENT'
-- fulfillment_type deal (e.g. 'web_store_order') never gets a project — its fulfillment already
-- lives on fact_web_orders.status — so this column stays NULL for those.
--
-- Safe to re-run: information_schema-guarded ALTER (no "already exists" error on MySQL/MariaDB
-- for ADD COLUMN), and the index is added the same guarded way since there's no
-- CREATE INDEX/ADD INDEX IF NOT EXISTS either.
-- =============================================================================================

SET @db := DATABASE();

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_projects' AND COLUMN_NAME='opportunity_id'),
  'ALTER TABLE dim_projects ADD COLUMN opportunity_id BIGINT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.STATISTICS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_projects' AND INDEX_NAME='idx_dim_projects_opportunity'),
  'ALTER TABLE dim_projects ADD INDEX idx_dim_projects_opportunity (opportunity_id)');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- ----- fix_vw_user_module_access_detail.sql -----
USE `stacie_Aggie_v1.0`;

-- Re-fixes vw_user_module_access_detail (2026-07-28). This view previously broke with
-- MySQL error 1356 ("references invalid table(s) or column(s)...") because its stored
-- definition referenced `uma.module_name`, but user_module_access's actual column is `module`
-- (see [[db_name_dot_quoting_bug]]/[[broken_views_definer_issue]] memory — this is the same
-- view that broke mysqldump entirely once before; it had drifted back out of sync with the
-- real column name since). Recreated with SQL SECURITY INVOKER (no DEFINER dependency) and
-- the correct source column, aliased back to `module_name` so existing consumers are unaffected.
-- Safe to re-run: CREATE OR REPLACE VIEW.

CREATE OR REPLACE SQL SECURITY INVOKER VIEW vw_user_module_access_detail AS
SELECT uma.id, uma.tenant_id, uma.user_id, u.email, uma.module AS module_name, uma.access_level
FROM user_module_access uma
JOIN users u ON uma.user_id = u.id
WHERE u.is_deleted = 0;

-- ----- projects_foundation_refactor.sql -----
USE `stacie_Aggie_v1.0`;

-- =============================================================================
-- Projects module — Foundation refactor (Phase 1).
--
-- Safe to re-run: apply-migrations.sh has no history table and re-applies every
-- script on every deploy, so each statement is information_schema-guarded.
-- Portable MySQL 8 / MariaDB: no JSON_VALUE(...RETURNING), no functional indexes.
--
-- Widens tinytext -> varchar (tinytext cannot be indexed without a prefix length
-- and cannot carry a DEFAULT, yet type/status/priority are NOT NULL and are
-- filtered in every list query), adds real user FK columns alongside the legacy
-- free-text ones, backfills business_id (decision: business_id = 1 for every
-- existing row, business scoping now filters strictly), and adds the composite
-- indexes every query already needs.
--
-- See PROJECTS-REFACTOR-PLAN.md at the repo root for full rationale.
-- =============================================================================

SET @db := DATABASE();

-- ── 1. dim_projects — widen the vocabulary columns ──────────────────────────
SET @sql := IFNULL((SELECT IF(DATA_TYPE='tinytext',
    'ALTER TABLE dim_projects MODIFY COLUMN type VARCHAR(50) NOT NULL', 'SELECT 1')
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_projects' AND COLUMN_NAME='type'), 'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT IF(DATA_TYPE='tinytext',
    'ALTER TABLE dim_projects MODIFY COLUMN status VARCHAR(50) NOT NULL', 'SELECT 1')
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_projects' AND COLUMN_NAME='status'), 'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT IF(DATA_TYPE='tinytext',
    'ALTER TABLE dim_projects MODIFY COLUMN priority VARCHAR(20) NOT NULL', 'SELECT 1')
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_projects' AND COLUMN_NAME='priority'), 'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT IF(DATA_TYPE='tinytext',
    'ALTER TABLE dim_projects MODIFY COLUMN description TEXT NULL', 'SELECT 1')
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_projects' AND COLUMN_NAME='description'), 'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- ── 2. dim_projects — real owner FK column (legacy `owner` text retained) ────
-- `owner` stays as the denormalized display name and the fallback for legacy
-- rows whose text never matches a user, so no existing screen loses data.
SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_projects' AND COLUMN_NAME='owner_user_id'),
  'ALTER TABLE dim_projects ADD COLUMN owner_user_id INT NULL AFTER owner');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- ── 3. dim_projects — indexes ───────────────────────────────────────────────
SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.STATISTICS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_projects' AND INDEX_NAME='idx_dim_projects_tenant_deleted' LIMIT 1),
  'ALTER TABLE dim_projects ADD INDEX idx_dim_projects_tenant_deleted (tenant_id, is_deleted)');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.STATISTICS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_projects' AND INDEX_NAME='idx_dim_projects_business' LIMIT 1),
  'ALTER TABLE dim_projects ADD INDEX idx_dim_projects_business (tenant_id, business_id, is_deleted)');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.STATISTICS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_projects' AND INDEX_NAME='idx_dim_projects_status' LIMIT 1),
  'ALTER TABLE dim_projects ADD INDEX idx_dim_projects_status (tenant_id, is_deleted, status)');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.STATISTICS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_projects' AND INDEX_NAME='idx_dim_projects_owner_user' LIMIT 1),
  'ALTER TABLE dim_projects ADD INDEX idx_dim_projects_owner_user (tenant_id, owner_user_id)');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- ── 4. fact_project_tasks — widen vocabulary columns ────────────────────────
SET @sql := IFNULL((SELECT IF(DATA_TYPE='tinytext',
    'ALTER TABLE fact_project_tasks MODIFY COLUMN type VARCHAR(50) NOT NULL', 'SELECT 1')
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_project_tasks' AND COLUMN_NAME='type'), 'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT IF(DATA_TYPE='tinytext',
    'ALTER TABLE fact_project_tasks MODIFY COLUMN status VARCHAR(50) NOT NULL', 'SELECT 1')
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_project_tasks' AND COLUMN_NAME='status'), 'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT IF(DATA_TYPE='tinytext',
    'ALTER TABLE fact_project_tasks MODIFY COLUMN priority VARCHAR(20) NOT NULL', 'SELECT 1')
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_project_tasks' AND COLUMN_NAME='priority'), 'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT IF(DATA_TYPE='tinytext',
    'ALTER TABLE fact_project_tasks MODIFY COLUMN description TEXT NULL', 'SELECT 1')
  FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_project_tasks' AND COLUMN_NAME='description'), 'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- ── 5. fact_project_tasks — real assignee FK column ─────────────────────────
SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_project_tasks' AND COLUMN_NAME='assigned_user_id'),
  'ALTER TABLE fact_project_tasks ADD COLUMN assigned_user_id INT NULL AFTER assigned_to');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- ── 6. fact_project_tasks — indexes ─────────────────────────────────────────
SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.STATISTICS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_project_tasks' AND INDEX_NAME='idx_fact_project_tasks_tenant_deleted' LIMIT 1),
  'ALTER TABLE fact_project_tasks ADD INDEX idx_fact_project_tasks_tenant_deleted (tenant_id, is_deleted)');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.STATISTICS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_project_tasks' AND INDEX_NAME='idx_fact_project_tasks_project' LIMIT 1),
  'ALTER TABLE fact_project_tasks ADD INDEX idx_fact_project_tasks_project (tenant_id, is_deleted, project_id)');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.STATISTICS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_project_tasks' AND INDEX_NAME='idx_fact_project_tasks_status' LIMIT 1),
  'ALTER TABLE fact_project_tasks ADD INDEX idx_fact_project_tasks_status (tenant_id, is_deleted, status)');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.STATISTICS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_project_tasks' AND INDEX_NAME='idx_fact_project_tasks_assignee' LIMIT 1),
  'ALTER TABLE fact_project_tasks ADD INDEX idx_fact_project_tasks_assignee (tenant_id, is_deleted, assigned_user_id)');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- Due-date index: needed now for overdue/date sorting, and is the access path
-- Phase 2's Today/Upcoming views will run on.
SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.STATISTICS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_project_tasks' AND INDEX_NAME='idx_fact_project_tasks_due' LIMIT 1),
  'ALTER TABLE fact_project_tasks ADD INDEX idx_fact_project_tasks_due (tenant_id, is_deleted, due_date)');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- Sections 7-9 below update on non-key columns (owner_user_id, business_id, status,
-- priority, type, progress) via literal UPDATEs and dynamic PREPARE/EXECUTE SQL alike.
-- MySQL Workbench/phpMyAdmin's client-side "safe update mode" (sql_safe_updates=1)
-- rejects any UPDATE whose WHERE doesn't reference a KEY column — which applies to the
-- session regardless of how the statement text arrives, so it also blocks the
-- PREPARE/EXECUTE ones. Toggle it off for this whole run of backfills/normalizations.
SET SQL_SAFE_UPDATES = 0;

-- ── 7. Best-effort backfill of the new user FK columns from legacy free text ─
-- Idempotent: the `IS NULL` guard means already-resolved rows are never retouched,
-- and rows whose text matches no user simply stay NULL and keep displaying the
-- legacy `owner`/`assigned_to` text.
UPDATE dim_projects p
  JOIN users u ON u.tenant_id = p.tenant_id AND u.is_deleted = 0
              AND TRIM(CONCAT(u.first_name, ' ', u.last_name)) = TRIM(p.owner)
   SET p.owner_user_id = u.id
 WHERE p.owner_user_id IS NULL AND p.owner IS NOT NULL AND TRIM(p.owner) <> '';

UPDATE fact_project_tasks t
  JOIN users u ON u.tenant_id = t.tenant_id AND u.is_deleted = 0
              AND TRIM(CONCAT(u.first_name, ' ', u.last_name)) = TRIM(t.assigned_to)
   SET t.assigned_user_id = u.id
 WHERE t.assigned_user_id IS NULL AND t.assigned_to IS NOT NULL AND TRIM(t.assigned_to) <> '';

-- ── 7b. Backfill business_id — REQUIRED before business filtering goes live ──
-- Every existing row predates business scoping and has business_id NULL. Once
-- findForList filters strictly on business_id, those rows become invisible.
-- Decision: assign business_id = 1 to all of them.
--
-- Idempotent (IS NULL guard) and non-destructive — this only fills blanks, it
-- never overwrites a business_id that is already set.
--
-- Guarded on business 1 actually existing, so a mistargeted environment fails
-- closed (prints a warning) rather than writing a dangling business_id.
SET @b1 := (SELECT COUNT(*) FROM businesses WHERE id = 1 AND is_deleted = 0);

SET @sql := IF(@b1 = 1,
  'UPDATE dim_projects SET business_id = 1 WHERE business_id IS NULL',
  'SELECT ''SKIPPED: business id 1 not found — project business_id backfill did not run'' AS warning');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- Tasks inherit from their parent project rather than being set independently,
-- so a task can never end up scoped to a different business than its project.
-- Must run after the project backfill above.
UPDATE fact_project_tasks t
  JOIN dim_projects p ON p.id = t.project_id
   SET t.business_id = p.business_id
 WHERE t.business_id IS NULL AND p.business_id IS NOT NULL;

-- Catches tasks whose parent project row is missing entirely (orphaned FK).
SET @sql := IF(@b1 = 1,
  'UPDATE fact_project_tasks SET business_id = 1 WHERE business_id IS NULL',
  'SELECT ''SKIPPED: business id 1 not found — task business_id backfill did not run'' AS warning');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- NOTE: tenant_id is intentionally NOT touched here. Unlike business_id (NULL on
-- every row today, so filling it is additive), tenant_id is NOT NULL and already
-- populated on every row — forcibly setting it to 1 would MOVE any non-tenant-1
-- project into tenant 1, merging tenants' data with no record of where a row
-- came from. See PROJECTS-REFACTOR-PLAN.md §3 step 7c for the pre-flight query
-- to confirm whether this even applies before considering it.

-- ── 8. Normalize out-of-vocabulary values written before validation existed ──
-- Free-text tinytext columns let arbitrary strings persist. Anything outside
-- the app's vocabulary is parked on the safe default so the new NOT NULL +
-- application-level whitelist cannot reject/misdisplay existing rows.
UPDATE dim_projects SET status = 'Planning'
 WHERE status NOT IN ('Planning','Active','On Hold','Completed','Cancelled');
UPDATE dim_projects SET priority = 'Medium'
 WHERE priority NOT IN ('Low','Medium','High','Critical');
UPDATE dim_projects SET type = 'Other'
 WHERE type NOT IN ('Tech','Ops','Admin','Training','Marketing','Other');

UPDATE fact_project_tasks SET status = 'Todo'
 WHERE status NOT IN ('Backlog','Todo','In Progress','Review','Done','Blocked');
UPDATE fact_project_tasks SET priority = 'Medium'
 WHERE priority NOT IN ('Low','Medium','High','Critical');
UPDATE fact_project_tasks SET type = 'Task'
 WHERE type NOT IN ('Feature','Bug','Task','Backlog','Epic');

-- ── 9. Clamp progress into range (no prior validation allowed anything) ─────
UPDATE dim_projects SET progress = 0   WHERE progress < 0;
UPDATE dim_projects SET progress = 100 WHERE progress > 100;

SET SQL_SAFE_UPDATES = 1;

-- =============================================================================
-- Web module — Foundation (Phase 1): sites, content collections, entries,
-- navigation, redirects, media.
--
-- Bundled into this same (not-yet-applied) migration file rather than a new
-- db/*.sql, so both land on the server in a single deploy. No ordering
-- dependency on the Projects section above — appended after it purely to keep
-- this file in commit order.
--
-- Safe to re-run: CREATE TABLE IF NOT EXISTS throughout (no history table —
-- apply-migrations.sh re-applies every script on every run).
--
-- Portable MySQL 8 / MariaDB: no JSON_VALUE(...RETURNING), no functional or
-- JSON indexes. Every column the public read path filters or sorts on is
-- promoted out of data_json into a real indexed column.
--
-- See WEB-MODULE-PLAN.md §3 at the repo root for full rationale, including the
-- standardisation contract (§3a) and the LarCare-table -> ABOS-table map (§3b).
-- =============================================================================

-- ── 10. dim_web_sites ────────────────────────────────────────────────────────
-- The root of the module and the ONLY thing that maps an anonymous public
-- request to a tenant. public_site_key is the credential the Next.js app sends
-- as X-Site-Key; it is public-read-safe but must be unique and unguessable.
CREATE TABLE IF NOT EXISTS dim_web_sites (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    name VARCHAR(255) NOT NULL,
    slug VARCHAR(100) NOT NULL,
    primary_domain VARCHAR(255) NOT NULL,
    additional_domains_json JSON NULL,
    public_site_key CHAR(48) NOT NULL,
    default_locale VARCHAR(10) NOT NULL DEFAULT 'en',
    is_published TINYINT NOT NULL DEFAULT 0,
    seo_title_template VARCHAR(255) NULL,
    default_meta_description VARCHAR(500) NULL,
    default_og_image VARCHAR(500) NULL,
    default_robots VARCHAR(100) NULL DEFAULT 'index,follow',
    favicon_url VARCHAR(500) NULL,
    google_analytics_id VARCHAR(50) NULL,
    google_tag_manager_id VARCHAR(50) NULL,
    google_site_verification VARCHAR(100) NULL,
    company_name VARCHAR(255) NULL,
    tagline VARCHAR(500) NULL,
    contact_phone VARCHAR(50) NULL,
    contact_email VARCHAR(255) NULL,
    address VARCHAR(500) NULL,
    regions_served_json JSON NULL,
    social_links_json JSON NULL,
    notify_emails VARCHAR(500) NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_dim_web_sites_key (public_site_key),
    UNIQUE KEY uq_dim_web_sites_domain (primary_domain),
    UNIQUE KEY uq_dim_web_sites_slug (tenant_id, slug),
    INDEX idx_dim_web_sites_tenant (tenant_id),
    INDEX idx_dim_web_sites_business (tenant_id, business_id, is_deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 11. dim_web_collections ──────────────────────────────────────────────────
-- A user-defined content type. kind='collection' -> many entries (blog posts);
-- kind='singleton' -> exactly one (the About page's stat block).
CREATE TABLE IF NOT EXISTS dim_web_collections (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    site_id BIGINT NOT NULL,
    collection_key VARCHAR(100) NOT NULL,
    label VARCHAR(255) NOT NULL,
    label_singular VARCHAR(255) NULL,
    kind VARCHAR(20) NOT NULL DEFAULT 'collection',
    route_pattern VARCHAR(255) NULL,
    has_seo_fields TINYINT NOT NULL DEFAULT 1,
    is_system TINYINT NOT NULL DEFAULT 0,
    sort_order INT NOT NULL DEFAULT 0,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_dim_web_collections_key (site_id, collection_key),
    INDEX idx_dim_web_collections_tenant (tenant_id),
    INDEX idx_dim_web_collections_site (site_id, is_deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 12. dim_web_collection_fields ────────────────────────────────────────────
-- The schema of a collection. Drives BOTH the admin editor's field rendering
-- and server-side validation of data_json on write.
CREATE TABLE IF NOT EXISTS dim_web_collection_fields (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    site_id BIGINT NOT NULL,
    collection_id BIGINT NOT NULL,
    field_key VARCHAR(100) NOT NULL,
    label VARCHAR(255) NOT NULL,
    field_type VARCHAR(40) NOT NULL,
    help_text VARCHAR(500) NULL,
    is_required TINYINT NOT NULL DEFAULT 0,
    is_localized TINYINT NOT NULL DEFAULT 0,
    max_length INT NULL,
    config_json JSON NULL,
    sort_order INT NOT NULL DEFAULT 0,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_dim_web_collection_fields (collection_id, field_key),
    INDEX idx_dim_web_collection_fields_tenant (tenant_id),
    INDEX idx_dim_web_collection_fields_coll (collection_id, is_deleted, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 13. fact_web_entries ─────────────────────────────────────────────────────
-- One content row. Everything the public read path filters or sorts on is a
-- real column; everything else lives in data_json, shaped by
-- dim_web_collection_fields.
CREATE TABLE IF NOT EXISTS fact_web_entries (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    site_id BIGINT NOT NULL,
    collection_id BIGINT NOT NULL,
    slug VARCHAR(255) NULL,
    title VARCHAR(500) NOT NULL,
    locale VARCHAR(10) NOT NULL DEFAULT 'en',
    status VARCHAR(20) NOT NULL DEFAULT 'draft',
    sort_order INT NOT NULL DEFAULT 0,
    is_featured TINYINT NOT NULL DEFAULT 0,
    published_at DATETIME NULL,
    data_json JSON NULL,
    meta_title VARCHAR(255) NULL,
    meta_description VARCHAR(500) NULL,
    meta_keywords VARCHAR(500) NULL,
    canonical_url VARCHAR(500) NULL,
    og_title VARCHAR(255) NULL,
    og_description VARCHAR(500) NULL,
    og_image VARCHAR(500) NULL,
    twitter_card VARCHAR(50) NULL,
    robots VARCHAR(100) NULL,
    structured_data_json JSON NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_fact_web_entries_slug (collection_id, locale, slug),
    INDEX idx_fact_web_entries_tenant (tenant_id),
    INDEX idx_fact_web_entries_public (site_id, collection_id, status, is_deleted, sort_order),
    INDEX idx_fact_web_entries_published (site_id, status, published_at),
    INDEX idx_fact_web_entries_featured (site_id, collection_id, is_featured, status),
    INDEX idx_fact_web_entries_business (tenant_id, business_id, is_deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 14. dim_web_navigation_items ─────────────────────────────────────────────
-- Replaces LarCare's nav_items (29 rows). Self-referencing for dropdowns.
CREATE TABLE IF NOT EXISTS dim_web_navigation_items (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    site_id BIGINT NOT NULL,
    parent_id BIGINT NULL,
    location VARCHAR(20) NOT NULL,
    column_title VARCHAR(255) NULL,
    label VARCHAR(255) NOT NULL,
    href VARCHAR(500) NOT NULL,
    open_in_new_tab TINYINT NOT NULL DEFAULT 0,
    sort_order INT NOT NULL DEFAULT 0,
    is_active TINYINT NOT NULL DEFAULT 1,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    INDEX idx_dim_web_nav_tenant (tenant_id),
    INDEX idx_dim_web_nav_public (site_id, location, is_active, is_deleted, sort_order),
    INDEX idx_dim_web_nav_parent (parent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 15. dim_web_redirects ────────────────────────────────────────────────────
-- SEO essential: every slug change must be able to leave a 301 behind.
CREATE TABLE IF NOT EXISTS dim_web_redirects (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    site_id BIGINT NOT NULL,
    from_path VARCHAR(500) NOT NULL,
    to_path VARCHAR(500) NOT NULL,
    status_code SMALLINT NOT NULL DEFAULT 301,
    is_active TINYINT NOT NULL DEFAULT 1,
    hit_count BIGINT NOT NULL DEFAULT 0,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_dim_web_redirects_from (site_id, from_path),
    INDEX idx_dim_web_redirects_tenant (tenant_id),
    INDEX idx_dim_web_redirects_active (site_id, is_active, is_deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 16. dim_web_media ────────────────────────────────────────────────────────
-- Minimal asset registry: alt text is the point. Files live on disk under
-- abos-api's configured upload root; only metadata is stored here.
CREATE TABLE IF NOT EXISTS dim_web_media (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    site_id BIGINT NOT NULL,
    storage_path VARCHAR(500) NOT NULL,
    public_url VARCHAR(500) NOT NULL,
    original_filename VARCHAR(255) NULL,
    mime_type VARCHAR(100) NULL,
    size_bytes BIGINT NULL,
    width_px INT NULL,
    height_px INT NULL,
    alt_text VARCHAR(500) NULL,
    caption VARCHAR(500) NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    INDEX idx_dim_web_media_tenant (tenant_id),
    INDEX idx_dim_web_media_site (site_id, is_deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- =============================================================================
-- Cross-module Labels — free-form, multi-select tags shared across CRM (Leads,
-- Deals), Projects (Tasks), and HRM (Employees).
--
-- Bundled into this same (not-yet-applied) migration file rather than a new
-- db/*.sql, so both land on the server in a single deploy — same rationale as
-- the Web module section above.
--
-- One shared, tenant-wide label catalog (dim_labels) rather than one per
-- module: a label like "Hot" or "Follow-up" is equally meaningful on a Lead,
-- a Task, or an Employee, and a shared catalog is what lets a user create it
-- once and reuse it everywhere, matching the "create label options" ask.
--
-- fact_entity_labels is a generic polymorphic junction (entity_type +
-- entity_id) rather than a join table per module (fact_lead_labels,
-- fact_task_labels, ...) — one table serves every current and future
-- labelable entity, and entity_id is a plain BIGINT independent of whether
-- the underlying record is JPA-backed (Leads, Tasks, Employees) or
-- stored-procedure-backed (Deals/Opportunities), so the label system never
-- needs to know or care how a given module persists its own records.
--
-- Safe to re-run: CREATE TABLE IF NOT EXISTS (no history table — every script
-- re-runs on every apply-migrations.sh run).
-- =============================================================================

-- ── 17. dim_labels ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS dim_labels (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    name VARCHAR(100) NOT NULL,
    color VARCHAR(20) NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_dim_labels_tenant_name (tenant_id, name),
    INDEX idx_dim_labels_tenant (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 18. fact_entity_labels ───────────────────────────────────────────────────
-- entity_type is an application-level vocabulary (LabelableEntityType), not an
-- FK-checked enum column — matches the "no FK constraints, vocabulary lives in
-- Java" convention used throughout db/ (see fact_leads.sql's status column).
CREATE TABLE IF NOT EXISTS fact_entity_labels (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    entity_type VARCHAR(30) NOT NULL,
    entity_id BIGINT NOT NULL,
    label_id BIGINT NOT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    UNIQUE KEY uq_fact_entity_labels (entity_type, entity_id, label_id),
    INDEX idx_fact_entity_labels_tenant (tenant_id),
    INDEX idx_fact_entity_labels_lookup (tenant_id, entity_type, entity_id),
    INDEX idx_fact_entity_labels_by_label (tenant_id, entity_type, label_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- =============================================================================
-- Communication — Mail module. Replaces the previously client-only/localStorage
-- mock (features/communication/components/Mail.tsx) with real, multi-tenant
-- mailbox connections (SMTP/POP3 generic, Gmail/Outlook OAuth2) plus agent
-- assignment and reply guidelines. Bundled into this same migration file for
-- the same single-deploy reason as the sections above.
--
-- dim_mail_account: one row per connected mailbox. Non-secret parameters live
-- in config_json (host/port/username/ssl-flag/etc, following DimWebSite's
-- existing JSON-blob-in-column pattern); the one secret a connection ever
-- needs (SMTP/POP3 password, or an OAuth refresh token) lives in
-- secret_encrypted, AES-256-GCM via the existing SecretDecryptionService (same
-- pattern already used for AI agent keys) — OAuth access tokens are never
-- persisted, only refreshed from the refresh token at point of use.
--
-- fact_mail_account_access: many-to-many grant table (mailbox <-> user), same
-- shape as the existing user_business_access grant table. owner_user_id on
-- dim_mail_account is implicit access and is not duplicated as a grant row
-- here — this table is only for *additional* users.
--
-- fact_mail_message: one row per sent/received message. sent_by_user_id
-- records who actually performed an outbound send (may differ from the
-- mailbox's owner_user_id when a granted user replies on the mailbox's
-- behalf — always recorded, not conditional). linked_entity_type/
-- linked_entity_id are a single optional pointer to a CRM record (Lead/Deal/
-- Contact/Client) — not a many-to-many junction, since a message either
-- points at one CRM record or it doesn't.
--
-- dim_communication_settings: one row per tenant (business_id NULL = tenant-
-- wide default) holding the "reply guidelines" persona/instructions text
-- shown to human agents while drafting a reply.
-- =============================================================================

-- ── 19. dim_mail_account ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS dim_mail_account (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    owner_user_id INT NOT NULL,
    display_name VARCHAR(255) NOT NULL,
    email_address VARCHAR(255) NOT NULL,
    provider_type VARCHAR(20) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    config_json LONGTEXT NULL,
    secret_encrypted LONGTEXT NULL,
    sync_cursor VARCHAR(255) NULL,
    last_synced_at TIMESTAMP(6) NULL,
    last_error LONGTEXT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    INDEX idx_dim_mail_account_tenant (tenant_id),
    INDEX idx_dim_mail_account_status (status, is_deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 20. fact_mail_account_access ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS fact_mail_account_access (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    mail_account_id BIGINT NOT NULL,
    user_id INT NOT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_fact_mail_account_access (mail_account_id, user_id),
    INDEX idx_fact_mail_account_access_account (tenant_id, mail_account_id),
    INDEX idx_fact_mail_account_access_user (tenant_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 21. fact_mail_message ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS fact_mail_message (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    mail_account_id BIGINT NOT NULL,
    folder VARCHAR(20) NOT NULL,
    provider_message_id VARCHAR(500) NULL,
    thread_id VARCHAR(255) NULL,
    from_address VARCHAR(255) NULL,
    to_addresses LONGTEXT NULL,
    cc_addresses LONGTEXT NULL,
    subject VARCHAR(998) NULL,
    body_text LONGTEXT NULL,
    body_html LONGTEXT NULL,
    sent_at TIMESTAMP(6) NULL,
    sent_by_user_id INT NULL,
    is_read TINYINT NOT NULL DEFAULT 0,
    is_starred TINYINT NOT NULL DEFAULT 0,
    assigned_user_id INT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'OPEN',
    linked_entity_type VARCHAR(20) NULL,
    linked_entity_id BIGINT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    INDEX idx_fact_mail_message_account_folder (tenant_id, mail_account_id, folder),
    INDEX idx_fact_mail_message_dedup (tenant_id, provider_message_id),
    INDEX idx_fact_mail_message_assignment (tenant_id, assigned_user_id, status),
    INDEX idx_fact_mail_message_link (tenant_id, linked_entity_type, linked_entity_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 22. dim_communication_settings ───────────────────────────────────────────
CREATE TABLE IF NOT EXISTS dim_communication_settings (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    reply_persona LONGTEXT NULL,
    reply_instructions LONGTEXT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    INDEX idx_dim_communication_settings_tenant (tenant_id, business_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- =============================================================================
-- Communication — Fireflies (meeting transcripts) + WhatsApp (inbox + /task
-- and /lead command bot). fact_meetings already exists (created by an earlier
-- migration) so its new columns use the guarded ALTER TABLE idiom from §2
-- above rather than CREATE TABLE; the new WhatsApp/Fireflies tables are new
-- and use the same CREATE TABLE IF NOT EXISTS style as the Mail tables above.
-- =============================================================================

-- ── 23. fact_meetings — Fireflies transcript columns ─────────────────────────
SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_meetings' AND COLUMN_NAME='fireflies_transcript_id'),
  'ALTER TABLE fact_meetings ADD COLUMN fireflies_transcript_id VARCHAR(255) NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_meetings' AND COLUMN_NAME='fireflies_transcript_url'),
  'ALTER TABLE fact_meetings ADD COLUMN fireflies_transcript_url VARCHAR(500) NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_meetings' AND COLUMN_NAME='fireflies_summary'),
  'ALTER TABLE fact_meetings ADD COLUMN fireflies_summary LONGTEXT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_meetings' AND COLUMN_NAME='fireflies_action_items'),
  'ALTER TABLE fact_meetings ADD COLUMN fireflies_action_items LONGTEXT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_meetings' AND COLUMN_NAME='fireflies_synced_at'),
  'ALTER TABLE fact_meetings ADD COLUMN fireflies_synced_at TIMESTAMP(6) NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- ── 24. dim_fireflies_account ────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS dim_fireflies_account (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    owner_user_id INT NOT NULL,
    display_name VARCHAR(255) NOT NULL,
    api_key_encrypted LONGTEXT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    last_synced_at TIMESTAMP(6) NULL,
    last_error LONGTEXT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    INDEX idx_dim_fireflies_account_tenant (tenant_id),
    INDEX idx_dim_fireflies_account_status (status, is_deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 25. dim_whatsapp_account ─────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS dim_whatsapp_account (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    owner_user_id INT NOT NULL,
    display_name VARCHAR(255) NOT NULL,
    phone_number VARCHAR(30) NULL,
    phone_number_id VARCHAR(100) NOT NULL,
    waba_id VARCHAR(100) NULL,
    access_token_encrypted LONGTEXT NULL,
    app_secret_encrypted LONGTEXT NULL,
    default_project_id BIGINT NULL,
    default_client_id BIGINT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    last_synced_at TIMESTAMP(6) NULL,
    last_error LONGTEXT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    INDEX idx_dim_whatsapp_account_tenant (tenant_id),
    UNIQUE KEY uq_dim_whatsapp_account_phone_number_id (phone_number_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 26. fact_whatsapp_account_access ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS fact_whatsapp_account_access (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    whatsapp_account_id BIGINT NOT NULL,
    user_id INT NOT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_fact_whatsapp_account_access (whatsapp_account_id, user_id),
    INDEX idx_fact_whatsapp_account_access_account (tenant_id, whatsapp_account_id),
    INDEX idx_fact_whatsapp_account_access_user (tenant_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 27. dim_whatsapp_known_sender ─────────────────────────────────────────────
-- Allow-list of phone numbers permitted to run the /task command (see
-- WhatsAppCommandService) — /lead is deliberately open to any sender since an
-- inbound prospect texting in IS the intended CRM-capture behavior, but /task
-- creates internal work items and must be restricted to known staff numbers.
CREATE TABLE IF NOT EXISTS dim_whatsapp_known_sender (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    whatsapp_account_id BIGINT NOT NULL,
    wa_id VARCHAR(30) NOT NULL,
    mapped_user_id INT NOT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_dim_whatsapp_known_sender (whatsapp_account_id, wa_id),
    INDEX idx_dim_whatsapp_known_sender_account (tenant_id, whatsapp_account_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 28. fact_whatsapp_message ────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS fact_whatsapp_message (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    whatsapp_account_id BIGINT NOT NULL,
    direction VARCHAR(10) NOT NULL,
    wa_message_id VARCHAR(255) NULL,
    contact_wa_id VARCHAR(30) NOT NULL,
    contact_name VARCHAR(255) NULL,
    body_text LONGTEXT NULL,
    sent_at TIMESTAMP(6) NULL,
    sent_by_user_id INT NULL,
    is_read TINYINT NOT NULL DEFAULT 0,
    assigned_user_id INT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'OPEN',
    linked_entity_type VARCHAR(20) NULL,
    linked_entity_id BIGINT NULL,
    command_result LONGTEXT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    INDEX idx_fact_whatsapp_message_account_contact (tenant_id, whatsapp_account_id, contact_wa_id),
    INDEX idx_fact_whatsapp_message_dedup (tenant_id, wa_message_id),
    INDEX idx_fact_whatsapp_message_assignment (tenant_id, assigned_user_id, status),
    INDEX idx_fact_whatsapp_message_link (tenant_id, linked_entity_type, linked_entity_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- =============================================================================
-- Campaigns — fixes a live gap (channel/currency/impressions/clicks/
-- conversions/ctr/roas/manager already exist on the frontend Campaign type
-- and were silently dropped on every save since fact_campaigns never had
-- them), then adds social platform connections (Facebook/Instagram/Twitter/
-- LinkedIn/TikTok — organic posts + paid ad campaigns, tokens pasted by the
-- user, no shared-app OAuth) and a full email-marketing tool as the "Email"
-- channel's implementation, plus the launch + scheduled-tracking plumbing
-- that ties a Campaign to whichever channel actually published/sent it.
-- =============================================================================

-- ── 29. fact_campaigns — the dropped fields, for real ────────────────────────
SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_campaigns' AND COLUMN_NAME='channel'),
  'ALTER TABLE fact_campaigns ADD COLUMN channel VARCHAR(30) NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_campaigns' AND COLUMN_NAME='currency'),
  'ALTER TABLE fact_campaigns ADD COLUMN currency VARCHAR(10) NULL DEFAULT ''USD''');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_campaigns' AND COLUMN_NAME='impressions'),
  'ALTER TABLE fact_campaigns ADD COLUMN impressions INT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_campaigns' AND COLUMN_NAME='clicks'),
  'ALTER TABLE fact_campaigns ADD COLUMN clicks INT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_campaigns' AND COLUMN_NAME='conversions'),
  'ALTER TABLE fact_campaigns ADD COLUMN conversions INT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_campaigns' AND COLUMN_NAME='ctr'),
  'ALTER TABLE fact_campaigns ADD COLUMN ctr DECIMAL(6,4) NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_campaigns' AND COLUMN_NAME='roas'),
  'ALTER TABLE fact_campaigns ADD COLUMN roas DECIMAL(8,2) NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_campaigns' AND COLUMN_NAME='manager'),
  'ALTER TABLE fact_campaigns ADD COLUMN manager VARCHAR(255) NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- ── 30. fact_campaigns — launch + tracking columns ───────────────────────────
SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_campaigns' AND COLUMN_NAME='social_account_id'),
  'ALTER TABLE fact_campaigns ADD COLUMN social_account_id BIGINT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_campaigns' AND COLUMN_NAME='campaign_mode'),
  'ALTER TABLE fact_campaigns ADD COLUMN campaign_mode VARCHAR(10) NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_campaigns' AND COLUMN_NAME='post_content'),
  'ALTER TABLE fact_campaigns ADD COLUMN post_content LONGTEXT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_campaigns' AND COLUMN_NAME='post_media_url'),
  'ALTER TABLE fact_campaigns ADD COLUMN post_media_url VARCHAR(500) NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_campaigns' AND COLUMN_NAME='external_campaign_id'),
  'ALTER TABLE fact_campaigns ADD COLUMN external_campaign_id VARCHAR(255) NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_campaigns' AND COLUMN_NAME='launched_at'),
  'ALTER TABLE fact_campaigns ADD COLUMN launched_at TIMESTAMP(6) NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_campaigns' AND COLUMN_NAME='email_template_id'),
  'ALTER TABLE fact_campaigns ADD COLUMN email_template_id BIGINT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_campaigns' AND COLUMN_NAME='email_recipient_list_id'),
  'ALTER TABLE fact_campaigns ADD COLUMN email_recipient_list_id BIGINT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_campaigns' AND COLUMN_NAME='mail_account_id'),
  'ALTER TABLE fact_campaigns ADD COLUMN mail_account_id BIGINT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- ── 31. dim_social_account ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS dim_social_account (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    owner_user_id INT NOT NULL,
    platform VARCHAR(20) NOT NULL,
    display_name VARCHAR(255) NOT NULL,
    access_token_encrypted LONGTEXT NULL,
    account_ref_id VARCHAR(255) NULL,
    ad_account_id VARCHAR(255) NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    last_synced_at TIMESTAMP(6) NULL,
    last_error LONGTEXT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    INDEX idx_dim_social_account_tenant (tenant_id),
    INDEX idx_dim_social_account_platform (tenant_id, platform, is_deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 32. fact_social_account_access ───────────────────────────────────────────
CREATE TABLE IF NOT EXISTS fact_social_account_access (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    social_account_id BIGINT NOT NULL,
    user_id INT NOT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_fact_social_account_access (social_account_id, user_id),
    INDEX idx_fact_social_account_access_account (tenant_id, social_account_id),
    INDEX idx_fact_social_account_access_user (tenant_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 33. dim_email_template ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS dim_email_template (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    name VARCHAR(255) NOT NULL,
    subject VARCHAR(998) NULL,
    body_html LONGTEXT NULL,
    body_text LONGTEXT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    INDEX idx_dim_email_template_tenant (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 34. dim_email_recipient_list ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS dim_email_recipient_list (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    name VARCHAR(255) NOT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    INDEX idx_dim_email_recipient_list_tenant (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 35. fact_email_recipient_list_member ─────────────────────────────────────
CREATE TABLE IF NOT EXISTS fact_email_recipient_list_member (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    list_id BIGINT NOT NULL,
    email VARCHAR(255) NOT NULL,
    name VARCHAR(255) NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    is_deleted TINYINT NOT NULL DEFAULT 0,
    INDEX idx_fact_email_recipient_list_member_list (tenant_id, list_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 36. dim_email_suppression ─────────────────────────────────────────────────
-- Tenant-wide unsubscribe/bounce list, checked before every send — not per
-- recipient-list, since an unsubscribe should stick regardless of which list
-- the address is later added to.
CREATE TABLE IF NOT EXISTS dim_email_suppression (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    email VARCHAR(255) NOT NULL,
    reason VARCHAR(50) NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    UNIQUE KEY uq_dim_email_suppression (tenant_id, email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 37. fact_email_campaign_send ─────────────────────────────────────────────
-- One row per recipient per campaign — the source of truth for an Email
-- campaign's impressions (=opens)/clicks/ctr, rolled up by CampaignSyncScheduler.
CREATE TABLE IF NOT EXISTS fact_email_campaign_send (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    campaign_id BIGINT NOT NULL,
    mail_account_id BIGINT NOT NULL,
    recipient_email VARCHAR(255) NOT NULL,
    sent_at TIMESTAMP(6) NULL,
    opened_at TIMESTAMP(6) NULL,
    first_clicked_at TIMESTAMP(6) NULL,
    click_count INT NOT NULL DEFAULT 0,
    unsubscribed_at TIMESTAMP(6) NULL,
    bounced_at TIMESTAMP(6) NULL,
    INDEX idx_fact_email_campaign_send_campaign (tenant_id, campaign_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ----- web_forms_module.sql -----
-- =============================================================================
-- web_forms_module.sql — Forms module for the Web CMS (dim_web_* family).
-- Adds dynamic form definitions + submission capture so public sites (e.g.
-- LarCare) can serve arbitrary forms (contact, sms-consent, ...) driven by
-- config instead of hardcoded backend routes.
--
-- Follows the exact tenant/business/site/audit column pattern used by the
-- other dim_web_* tables in projects_foundation_refactor.sql.
-- Safe to re-run: CREATE TABLE IF NOT EXISTS.
-- =============================================================================

-- ── dim_web_forms ────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS dim_web_forms (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    site_id BIGINT NOT NULL,
    form_key VARCHAR(100) NOT NULL,
    name VARCHAR(255) NULL,
    success_message VARCHAR(500) NULL,
    notify_emails VARCHAR(500) NULL,
    is_active TINYINT(1) NOT NULL DEFAULT 1,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_dim_web_forms_key (site_id, form_key),
    INDEX idx_dim_web_forms_tenant (tenant_id),
    INDEX idx_dim_web_forms_site (site_id, is_deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── dim_web_form_fields ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS dim_web_form_fields (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    form_id BIGINT NOT NULL,
    field_key VARCHAR(100) NOT NULL,
    label VARCHAR(255) NULL,
    field_type VARCHAR(50) NULL,
    is_required TINYINT(1) NOT NULL DEFAULT 0,
    sort_order INT NOT NULL DEFAULT 0,
    config_json TEXT NULL,
    UNIQUE KEY uq_dim_web_form_fields (form_id, field_key),
    INDEX idx_dim_web_form_fields_form (form_id, sort_order),
    CONSTRAINT fk_dim_web_form_fields_form FOREIGN KEY (form_id) REFERENCES dim_web_forms (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── fact_web_form_submissions ────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS fact_web_form_submissions (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    form_id BIGINT NOT NULL,
    site_id BIGINT NOT NULL,
    data_json TEXT NOT NULL,
    ip_hash VARCHAR(128) NULL,
    user_agent VARCHAR(500) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_fact_web_form_submissions_form (form_id, created_at),
    INDEX idx_fact_web_form_submissions_site (site_id, created_at),
    CONSTRAINT fk_fact_web_form_submissions_form FOREIGN KEY (form_id) REFERENCES dim_web_forms (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ----- web_store_module.sql -----
USE `stacie_Aggie_v1.0`;

-- =============================================================================
-- Web module — Store (Phase A of the Webflow/Shopify-style Website upgrade).
--
-- New, self-contained schema for storefront commerce: catalog (products +
-- variants), storefront customers, carts, and orders. Deliberately NOT reusing
-- the back-office `inv_products`/`fact_orders` tables — those have no
-- site/business scoping, no images/slug/SEO, no variants, and no order-line
-- model, so mixing them with public storefront data would conflate two
-- different concerns. Follows the exact tenant/business/site/audit column
-- convention already used by dim_web_sites/dim_web_collections/fact_web_entries
-- (see projects_foundation_refactor.sql "10-14").
--
-- Safe to re-run: CREATE TABLE IF NOT EXISTS throughout.
-- =============================================================================

-- ── 1. dim_web_products ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS dim_web_products (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    site_id BIGINT NOT NULL,
    slug VARCHAR(255) NOT NULL,
    name VARCHAR(255) NOT NULL,
    description TEXT NULL,
    images_json JSON NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'draft',
    category_key VARCHAR(100) NULL,
    currency VARCHAR(10) NOT NULL DEFAULT 'USD',
    base_price DECIMAL(15,2) NULL,
    meta_title VARCHAR(255) NULL,
    meta_description VARCHAR(500) NULL,
    sort_order INT NOT NULL DEFAULT 0,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_dim_web_products_slug (site_id, slug),
    INDEX idx_dim_web_products_tenant (tenant_id),
    INDEX idx_dim_web_products_public (site_id, status, is_deleted, sort_order),
    INDEX idx_dim_web_products_business (tenant_id, business_id, is_deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 2. dim_web_product_variants ──────────────────────────────────────────────
-- One product may have many purchasable variants (size/color/etc, each with
-- its own SKU/price/stock). A product with no real options still gets exactly
-- one variant row (is_default=1) so checkout/cart/order-item logic always
-- operates on a variant_id, never a bare product_id.
CREATE TABLE IF NOT EXISTS dim_web_product_variants (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    site_id BIGINT NOT NULL,
    product_id BIGINT NOT NULL,
    sku VARCHAR(100) NOT NULL,
    option_values_json JSON NULL,
    price DECIMAL(15,2) NOT NULL,
    compare_at_price DECIMAL(15,2) NULL,
    stock_qty INT NOT NULL DEFAULT 0,
    is_default TINYINT NOT NULL DEFAULT 0,
    sort_order INT NOT NULL DEFAULT 0,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_dim_web_product_variants_sku (site_id, sku),
    INDEX idx_dim_web_product_variants_tenant (tenant_id),
    INDEX idx_dim_web_product_variants_product (product_id, is_deleted, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 3. dim_web_customers ─────────────────────────────────────────────────────
-- Storefront customers — deliberately separate from internal `users` (a
-- storefront customer never logs into ABOS itself).
CREATE TABLE IF NOT EXISTS dim_web_customers (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    site_id BIGINT NOT NULL,
    email VARCHAR(255) NOT NULL,
    first_name VARCHAR(150) NULL,
    last_name VARCHAR(150) NULL,
    phone VARCHAR(50) NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_dim_web_customers_email (site_id, email),
    INDEX idx_dim_web_customers_tenant (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 4. fact_web_carts ────────────────────────────────────────────────────────
-- cart_token is the credential an anonymous storefront visitor's browser holds
-- (cookie), the same "unguessable public credential" shape as
-- dim_web_sites.public_site_key — never the numeric id.
CREATE TABLE IF NOT EXISTS fact_web_carts (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    site_id BIGINT NOT NULL,
    customer_id BIGINT NULL,
    cart_token CHAR(48) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'open',
    currency VARCHAR(10) NOT NULL DEFAULT 'USD',
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    is_deleted TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_fact_web_carts_token (cart_token),
    INDEX idx_fact_web_carts_tenant (tenant_id),
    INDEX idx_fact_web_carts_site (site_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 5. fact_web_cart_items ───────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS fact_web_cart_items (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    cart_id BIGINT NOT NULL,
    variant_id BIGINT NOT NULL,
    quantity INT NOT NULL DEFAULT 1,
    unit_price DECIMAL(15,2) NOT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    UNIQUE KEY uq_fact_web_cart_items (cart_id, variant_id),
    INDEX idx_fact_web_cart_items_tenant (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 6. fact_web_orders ───────────────────────────────────────────────────────
-- status mirrors fact_orders' vocabulary (Pending/Processing/Shipped/
-- Delivered/Cancelled) for consistency; payment_status is a separate axis
-- since a Shipped order and an unpaid order are different failure modes.
CREATE TABLE IF NOT EXISTS fact_web_orders (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    site_id BIGINT NOT NULL,
    customer_id BIGINT NULL,
    order_no VARCHAR(50) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'Pending',
    payment_status VARCHAR(20) NOT NULL DEFAULT 'Unpaid',
    currency VARCHAR(10) NOT NULL DEFAULT 'USD',
    subtotal_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
    tax_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
    shipping_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
    discount_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
    total_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
    customer_email VARCHAR(255) NOT NULL,
    shipping_name VARCHAR(255) NULL,
    shipping_address VARCHAR(500) NULL,
    shipping_city VARCHAR(150) NULL,
    shipping_state VARCHAR(150) NULL,
    shipping_postal_code VARCHAR(30) NULL,
    shipping_country VARCHAR(100) NULL,
    stripe_checkout_session_id VARCHAR(255) NULL,
    stripe_payment_intent_id VARCHAR(255) NULL,
    notes TEXT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_fact_web_orders_no (site_id, order_no),
    INDEX idx_fact_web_orders_tenant (tenant_id),
    INDEX idx_fact_web_orders_site (site_id, status, is_deleted),
    INDEX idx_fact_web_orders_business (tenant_id, business_id, is_deleted),
    INDEX idx_fact_web_orders_stripe_session (stripe_checkout_session_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 7. fact_web_order_items ──────────────────────────────────────────────────
-- product_name/sku/unit_price are snapshotted at purchase time — an order
-- must never change retroactively because a product/variant was edited or
-- deleted later.
CREATE TABLE IF NOT EXISTS fact_web_order_items (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    order_id BIGINT NOT NULL,
    variant_id BIGINT NULL,
    product_name VARCHAR(255) NOT NULL,
    sku VARCHAR(100) NOT NULL,
    unit_price DECIMAL(15,2) NOT NULL,
    quantity INT NOT NULL,
    line_total DECIMAL(15,2) NOT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    INDEX idx_fact_web_order_items_tenant (tenant_id),
    INDEX idx_fact_web_order_items_order (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ----- web_store_checkout_procedures.sql -----
USE `stacie_Aggie_v1.0`;

-- =============================================================================================
-- web_store_checkout_procedures.sql — the money-movement half of the storefront checkout flow
-- (companion to web_store_module.sql, applied right after it in apply-migrations.sh). Mirrors
-- the exact debit/credit/ledger-entry/balance-update transactional shape already used by
-- sp_pay_invoice/sp_transfer_wallet/sp_pay_commission (see fix_finance_wallet_procedures.sql and
-- fix_finance_hrm_platform_procedures.sql) — same fact_transactions + fact_ledger_entries +
-- dim_wallets.balance triple-write inside one START TRANSACTION/COMMIT, with the same
-- EXIT HANDLER FOR SQLEXCEPTION rollback guard.
--
-- Unlike sp_pay_invoice (a wallet-to-wallet transfer between two ABOS-internal wallets), a paid
-- storefront order's money comes from an external payment processor (Stripe), not from another
-- wallet this app tracks — so there is no "from" wallet to debit. Only one ledger effect exists:
-- crediting the tenant's corporate wallet for the order total. from_wallet_id is left NULL on the
-- fact_transactions row for that reason (the column is nullable — see fact_transactions schema).
--
-- Idempotency: StripeWebhookController/StripeWebhookService already check
-- fact_web_orders.payment_status = 'Unpaid' before calling this procedure (guards against a
-- redelivered Stripe webhook double-processing the same order). This procedure re-checks the same
-- condition itself, under a row lock (SELECT ... FOR UPDATE), so two concurrent webhook deliveries
-- for the same order can never both pass the check and both credit the wallet — the second call
-- becomes a no-op once the first has committed the status flip.
--
-- Safe to re-run: DROP PROCEDURE IF EXISTS precedes the CREATE.
-- =============================================================================================

DROP PROCEDURE IF EXISTS `sp_pay_web_order`;

DELIMITER $$

CREATE PROCEDURE `sp_pay_web_order`(
    IN p_tenant_id INT,
    IN p_order_id BIGINT,
    IN p_corp_wallet_id INT,
    IN p_updated_by INT
)
BEGIN
    DECLARE v_total_amount DECIMAL(15,2);
    DECLARE v_payment_status VARCHAR(20);
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;

    START TRANSACTION;

    -- FOR UPDATE locks this order row for the duration of the transaction, so a second,
    -- concurrently-processed redelivery of the same Stripe webhook blocks here until the first
    -- call's COMMIT, then sees payment_status already 'Paid' and does nothing further.
    SELECT total_amount, payment_status INTO v_total_amount, v_payment_status
    FROM fact_web_orders
    WHERE id = p_order_id AND tenant_id = p_tenant_id
    FOR UPDATE;

    IF v_payment_status = 'Unpaid' THEN
        UPDATE fact_web_orders
        SET payment_status = 'Paid', updated_by = p_updated_by
        WHERE id = p_order_id AND tenant_id = p_tenant_id;

        INSERT INTO fact_transactions (
            tenant_id, from_wallet_id, to_wallet_id, amount, description, reference_entity,
            reference_id, transaction_type, status, created_by
        ) VALUES (
            p_tenant_id, NULL, p_corp_wallet_id, v_total_amount,
            CONCAT('Payment for Web Order #', p_order_id), 'WebOrder', p_order_id, 'Income',
            'Completed', p_updated_by
        );

        INSERT INTO fact_ledger_entries (tenant_id, wallet_id, entry_type, amount, reference_entity, reference_id, created_by)
        VALUES (p_tenant_id, p_corp_wallet_id, 'Credit', v_total_amount, 'WebOrder', p_order_id, p_updated_by);

        UPDATE dim_wallets
        SET balance = balance + v_total_amount
        WHERE id = p_corp_wallet_id AND tenant_id = p_tenant_id;
    END IF;

    COMMIT;
END$$

DELIMITER ;

-- ----- web_pages_module.sql -----
-- =============================================================================
-- web_pages_module.sql — Page builder module for the Web CMS (dim_web_* family).
-- Adds (a) a `theme_json` design-token column on dim_web_sites (see
-- WEB-THEME-DESIGN-CONTEXT.md §1) and (b) dim_web_pages, storing each page's
-- `layout_json` block tree (see WEB-THEME-DESIGN-CONTEXT.md §2).
--
-- Follows the exact tenant/business/site/audit column pattern used by the
-- other dim_web_* tables in projects_foundation_refactor.sql / web_forms_module.sql.
-- Safe to re-run: CREATE TABLE IF NOT EXISTS + an information_schema-guarded
-- ALTER (the same @db/@sql/PREPARE pattern used throughout
-- projects_foundation_refactor.sql), avoiding MySQL-8-only
-- `ADD COLUMN IF NOT EXISTS` syntax that MariaDB (the production server —
-- see apply-migrations.sh / fix_sp_process_ai_bank_statement.sql's own
-- portability note) does not accept in the same form on every version.
-- =============================================================================

SET @db := DATABASE();

-- ── dim_web_sites.theme_json ─────────────────────────────────────────────────
-- One design-token object per site (WEB-THEME-DESIGN-CONTEXT.md §1). Editable
-- from the admin "Site Settings" screen via UPDATE_WEB_SITE_THEME; no code
-- change required to ship a new look.
SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_web_sites' AND COLUMN_NAME='theme_json'),
  'ALTER TABLE dim_web_sites ADD COLUMN theme_json JSON NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- ── dim_web_pages ─────────────────────────────────────────────────────────────
-- One row per page. layout_json is the `{ version, blocks: [...] }` block tree
-- (WEB-THEME-DESIGN-CONTEXT.md §2) rendered by both the builder's live preview
-- and the public site renderer. Stored/served verbatim — the backend does not
-- parse or validate individual block types.
CREATE TABLE IF NOT EXISTS dim_web_pages (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    site_id BIGINT NOT NULL,
    slug VARCHAR(255) NOT NULL,
    title VARCHAR(255) NOT NULL,
    route VARCHAR(255) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'draft',
    layout_json JSON NULL,
    meta_title VARCHAR(255) NULL,
    meta_description VARCHAR(500) NULL,
    og_image VARCHAR(500) NULL,
    sort_order INT NOT NULL DEFAULT 0,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_dim_web_pages_slug (site_id, slug),
    UNIQUE KEY uq_dim_web_pages_route (site_id, route),
    INDEX idx_dim_web_pages_tenant (tenant_id),
    INDEX idx_dim_web_pages_business (tenant_id, business_id, is_deleted),
    INDEX idx_dim_web_pages_site_status (site_id, status, is_deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ----- fix_enterprise_plan_missing_web_module.sql -----
-- The WEB (Website/CMS) module was added to AppModule.java after the Enterprise plan's
-- `modules` seed data was written, so ModuleAccessFilter rejects every /api/web/** request
-- with 403 MODULE_ACCESS_DENIED for every tenant, regardless of role — TENANT_ADMIN included.
-- This adds "WEB" to any plan missing it. Idempotent (JSON_CONTAINS-guarded), additive only.
-- Deploy: mysql -u root -p `stacie_Aggie_v1.0` < db/fix_enterprise_plan_missing_web_module.sql

USE `stacie_Aggie_v1.0`;

UPDATE dim_subscription_plans
SET modules = JSON_ARRAY_APPEND(modules, '$', 'WEB')
WHERE NOT JSON_CONTAINS(modules, '"WEB"');

-- ----- seed_larcare_business_and_site.sql -----
-- =============================================================================
-- seed_larcare_business_and_site.sql — Onboard LarCare as a Business + public
-- website inside ABOS's tenant/business-aware Web CMS module.
--
-- Source of truth for the seeded content: LarCare\frontend\src\lib\content\
-- {site,services,blog,products,misc}.json and LarCare\frontend\src\lib\types.ts
-- (as of the LarCare-to-ABOS migration).
--
-- Idempotent: every INSERT is guarded with `WHERE NOT EXISTS (...)` against the
-- table's natural/unique key, matching the pattern used by app_settings.sql.
-- Safe to re-run.
--
-- IMPORTANT: public_site_key below is the fixed, hardcoded X-Site-Key LarCare's
-- frontend must send to ABOS's public Web API. Value:
--   pSlfeq9OCm5MvZyoFguIK7jy0uz2aKShJkxZxTSWXwzPOpYJ
-- =============================================================================

SET @tenant_id := 1;

-- ── 1. Business ──────────────────────────────────────────────────────────────
INSERT INTO businesses (tenant_id, name, code, industry, country, email, phone, address, status, created_by, updated_by, is_deleted)
SELECT @tenant_id, 'LarCare Services', 'LARCARE', 'Healthcare', 'United States',
       'info@larcareservices.com', '803-209-8667', '178 Columbia St, Chester, SC 29706',
       'Active', NULL, NULL, 0
WHERE NOT EXISTS (
    SELECT 1 FROM businesses WHERE tenant_id = @tenant_id AND name = 'LarCare Services'
);

SET @business_id := (SELECT id FROM businesses WHERE tenant_id = @tenant_id AND name = 'LarCare Services' LIMIT 1);

-- ── 2. Site ──────────────────────────────────────────────────────────────────
INSERT INTO dim_web_sites (
    tenant_id, business_id, name, slug, primary_domain, public_site_key,
    default_locale, is_published,
    company_name, tagline, contact_phone, contact_email, address,
    regions_served_json, social_links_json,
    created_by, updated_by, is_deleted
)
SELECT
    @tenant_id, @business_id, 'LarCare Services', 'larcare', 'larcareservices.com',
    'pSlfeq9OCm5MvZyoFguIK7jy0uz2aKShJkxZxTSWXwzPOpYJ',
    'en', 1,
    'LarCare Services', 'Personal home care for every unique situation',
    '803-209-8667', 'info@larcareservices.com', '178 Columbia St, Chester, SC 29706',
    JSON_ARRAY('South Carolina', 'North Carolina', 'Georgia'),
    JSON_ARRAY(
        JSON_OBJECT('platform', 'facebook', 'url', 'https://facebook.com/larcareservices'),
        JSON_OBJECT('platform', 'x', 'url', 'https://x.com/larcareservices'),
        JSON_OBJECT('platform', 'instagram', 'url', 'https://instagram.com/larcareservices'),
        JSON_OBJECT('platform', 'linkedin', 'url', 'https://linkedin.com/company/larcareservices')
    ),
    NULL, NULL, 0
WHERE NOT EXISTS (
    SELECT 1 FROM dim_web_sites WHERE tenant_id = @tenant_id AND slug = 'larcare'
);

SET @site_id := (SELECT id FROM dim_web_sites WHERE tenant_id = @tenant_id AND slug = 'larcare' LIMIT 1);

-- ── 3. Collections ───────────────────────────────────────────────────────────
INSERT INTO dim_web_collections (tenant_id, business_id, site_id, collection_key, label, label_singular, kind, route_pattern, has_seo_fields, is_system, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, 'care-services', 'Care Services', 'Care Service', 'collection', '/healthcare/{slug}', 1, 1, 1, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collections WHERE site_id = @site_id AND collection_key = 'care-services');

INSERT INTO dim_web_collections (tenant_id, business_id, site_id, collection_key, label, label_singular, kind, route_pattern, has_seo_fields, is_system, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, 'blog-posts', 'Blog Posts', 'Blog Post', 'collection', '/blog/{slug}', 1, 1, 2, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collections WHERE site_id = @site_id AND collection_key = 'blog-posts');

INSERT INTO dim_web_collections (tenant_id, business_id, site_id, collection_key, label, label_singular, kind, route_pattern, has_seo_fields, is_system, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, 'products', 'Products', 'Product', 'collection', '/products/{slug}', 0, 1, 3, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collections WHERE site_id = @site_id AND collection_key = 'products');

INSERT INTO dim_web_collections (tenant_id, business_id, site_id, collection_key, label, label_singular, kind, route_pattern, has_seo_fields, is_system, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, 'testimonials', 'Testimonials', 'Testimonial', 'collection', NULL, 0, 1, 4, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collections WHERE site_id = @site_id AND collection_key = 'testimonials');

INSERT INTO dim_web_collections (tenant_id, business_id, site_id, collection_key, label, label_singular, kind, route_pattern, has_seo_fields, is_system, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, 'core-values', 'Core Values', 'Core Value', 'collection', NULL, 0, 1, 5, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collections WHERE site_id = @site_id AND collection_key = 'core-values');

INSERT INTO dim_web_collections (tenant_id, business_id, site_id, collection_key, label, label_singular, kind, route_pattern, has_seo_fields, is_system, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, 'about-stats', 'About Stats', 'About Stat', 'collection', NULL, 0, 1, 6, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collections WHERE site_id = @site_id AND collection_key = 'about-stats');

INSERT INTO dim_web_collections (tenant_id, business_id, site_id, collection_key, label, label_singular, kind, route_pattern, has_seo_fields, is_system, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, 'job-openings', 'Job Openings', 'Job Opening', 'collection', '/about-us/careers/{slug}', 0, 1, 7, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collections WHERE site_id = @site_id AND collection_key = 'job-openings');

SET @coll_care_services := (SELECT id FROM dim_web_collections WHERE site_id = @site_id AND collection_key = 'care-services' LIMIT 1);
SET @coll_blog_posts    := (SELECT id FROM dim_web_collections WHERE site_id = @site_id AND collection_key = 'blog-posts' LIMIT 1);
SET @coll_products      := (SELECT id FROM dim_web_collections WHERE site_id = @site_id AND collection_key = 'products' LIMIT 1);
SET @coll_testimonials  := (SELECT id FROM dim_web_collections WHERE site_id = @site_id AND collection_key = 'testimonials' LIMIT 1);
SET @coll_core_values   := (SELECT id FROM dim_web_collections WHERE site_id = @site_id AND collection_key = 'core-values' LIMIT 1);
SET @coll_about_stats   := (SELECT id FROM dim_web_collections WHERE site_id = @site_id AND collection_key = 'about-stats' LIMIT 1);
SET @coll_job_openings  := (SELECT id FROM dim_web_collections WHERE site_id = @site_id AND collection_key = 'job-openings' LIMIT 1);

-- ── 4. Collection fields ─────────────────────────────────────────────────────
-- care-services: shortDescription, heroImage, bodyHtml, highlights[], isFeaturedOnHome
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_care_services, 'shortDescription', 'Short Description', 'textarea', 1, 1, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_care_services AND field_key = 'shortDescription');
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_care_services, 'heroImage', 'Hero Image', 'image', 0, 2, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_care_services AND field_key = 'heroImage');
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_care_services, 'bodyHtml', 'Body HTML', 'richtext', 1, 3, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_care_services AND field_key = 'bodyHtml');
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_care_services, 'highlights', 'Highlights', 'json_array', 0, 4, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_care_services AND field_key = 'highlights');
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_care_services, 'isFeaturedOnHome', 'Featured on Home', 'boolean', 0, 5, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_care_services AND field_key = 'isFeaturedOnHome');

-- blog-posts: excerpt, coverImage, bodyHtml, author, publishedAt, tags[]
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_blog_posts, 'excerpt', 'Excerpt', 'textarea', 1, 1, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_blog_posts AND field_key = 'excerpt');
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_blog_posts, 'coverImage', 'Cover Image', 'image', 0, 2, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_blog_posts AND field_key = 'coverImage');
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_blog_posts, 'bodyHtml', 'Body HTML', 'richtext', 1, 3, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_blog_posts AND field_key = 'bodyHtml');
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_blog_posts, 'author', 'Author', 'text', 0, 4, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_blog_posts AND field_key = 'author');
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_blog_posts, 'publishedAt', 'Published At', 'date', 0, 5, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_blog_posts AND field_key = 'publishedAt');
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_blog_posts, 'tags', 'Tags', 'json_array', 0, 6, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_blog_posts AND field_key = 'tags');

-- products: description, image, price, externalUrl
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_products, 'description', 'Description', 'textarea', 0, 1, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_products AND field_key = 'description');
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_products, 'image', 'Image', 'image', 0, 2, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_products AND field_key = 'image');
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_products, 'price', 'Price', 'number', 0, 3, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_products AND field_key = 'price');
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_products, 'externalUrl', 'External URL', 'url', 0, 4, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_products AND field_key = 'externalUrl');

-- testimonials: authorName, rating, quote
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_testimonials, 'authorName', 'Author Name', 'text', 1, 1, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_testimonials AND field_key = 'authorName');
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_testimonials, 'rating', 'Rating', 'number', 0, 2, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_testimonials AND field_key = 'rating');
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_testimonials, 'quote', 'Quote', 'textarea', 1, 3, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_testimonials AND field_key = 'quote');

-- core-values: icon, title(reuse entry title), description
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_core_values, 'icon', 'Icon', 'text', 0, 1, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_core_values AND field_key = 'icon');
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_core_values, 'description', 'Description', 'textarea', 1, 2, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_core_values AND field_key = 'description');

-- about-stats: icon, title
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_about_stats, 'icon', 'Icon', 'text', 0, 1, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_about_stats AND field_key = 'icon');

-- job-openings: type, location, summary
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_job_openings, 'type', 'Type', 'text', 0, 1, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_job_openings AND field_key = 'type');
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_job_openings, 'location', 'Location', 'text', 0, 2, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_job_openings AND field_key = 'location');
INSERT INTO dim_web_collection_fields (tenant_id, business_id, site_id, collection_id, field_key, label, field_type, is_required, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_job_openings, 'summary', 'Summary', 'textarea', 1, 3, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_collection_fields WHERE collection_id = @coll_job_openings AND field_key = 'summary');

-- ── 5. Entries: care-services (from services.json) ──────────────────────────
INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, is_featured, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_care_services, 'home-and-personal-care-services', 'Home and Personal Care Services', 'published', 1, 1,
    JSON_OBJECT('shortDescription', 'Non-medical assistance, companionship, and daily living support delivered in the comfort of home.',
                'heroImage', '/images/services/home-personal-care.jpg',
                'bodyHtml', '<p>LarCare understands what families are going through and we are here to help. Home Care provides non-medical assistance to older adults or individuals with disabilities, directly within their homes. The core of companion care lies in offering emotional support and fostering social interaction.</p><p>Our certified nurse assistants (CNAs) offer a helping hand with essential activities of daily living such as bathing, dressing, and meal preparation. Beyond these, our services can include help with transportation, managing finances, scheduling appointments, and offering companionship and emotional support.</p>',
                'highlights', JSON_ARRAY('Meal planning & preparation', 'Bathing, grooming & personal hygiene', 'Light housekeeping & laundry', 'Medication reminders', 'Mobility, transferring & toileting assistance', 'Errands & transportation'),
                'isFeaturedOnHome', true),
    NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_care_services AND slug = 'home-and-personal-care-services');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, is_featured, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_care_services, 'alzheimers-dementia-care', 'Alzheimer''s Disease and Dementia Care', 'published', 2, 0,
    JSON_OBJECT('shortDescription', 'Specialized, patient-centered memory care for every stage, tailored to each family''s experience.',
                'heroImage', '/images/services/dementia-care.jpg',
                'bodyHtml', '<p>Alzheimer''s disease, a degenerative and incurable disorder, progressively impairs memory, cognitive abilities, and the capacity to carry out daily activities. At LarCare, we recognize the challenges that come with caring for seniors with Alzheimer''s disease or dementia. Our team is specially trained to support individuals with these conditions, ensuring they receive compassionate and expert care.</p>',
                'highlights', JSON_ARRAY('Structured, memory-focused routines', 'Specially trained dementia caregivers', 'Behavioral and mood support', 'Family guidance and education', 'Safety-focused home environment checks'),
                'isFeaturedOnHome', false),
    NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_care_services AND slug = 'alzheimers-dementia-care');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, is_featured, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_care_services, 'end-of-life-care', 'End-of-Life Care', 'published', 3, 1,
    JSON_OBJECT('shortDescription', 'Physical, emotional, and spiritual comfort for patients and families during life''s most crucial time.',
                'heroImage', '/images/services/end-of-life-care.jpg',
                'bodyHtml', '<p>LarCare is dedicated to providing both physical and emotional comfort to our patients and their families during crucial times. Our skilled team of compassionate professionals is committed to delivering exceptional medical, emotional, and spiritual support.</p><p>Hospice care is in-home medical care for people who are no longer seeking a cure for their advanced illness; instead, hospice care focuses on achieving comfort for mind, body and spirit.</p>',
                'highlights', JSON_ARRAY('Dignity-centered hospice support', '24/7 comfort-focused caregiving', 'Family emotional support', 'Coordination with medical providers'),
                'isFeaturedOnHome', true),
    NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_care_services AND slug = 'end-of-life-care');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, is_featured, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_care_services, 'transitional-care', 'Transitional Care', 'published', 4, 0,
    JSON_OBJECT('shortDescription', 'Easing the move from hospital or rehab facility back home for a safer post-surgery recovery.',
                'heroImage', '/images/services/transitional-care.jpg',
                'bodyHtml', '<p>We can ease your loved one''s transition from the hospital or rehabilitation facility to home, which can lead to a safer, more comfortable post-surgery recovery. Professional in-home caregivers provide care that can help you avoid hospital readmission.</p>',
                'highlights', JSON_ARRAY('Medication reminders', 'Doctor-ordered diet support', 'Mobility & exercise monitoring', 'Fall-risk prevention'),
                'isFeaturedOnHome', false),
    NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_care_services AND slug = 'transitional-care');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, is_featured, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_care_services, 'veteran-care', 'Veteran Care', 'published', 5, 1,
    JSON_OBJECT('shortDescription', 'Home Health Aide services for Veterans, coordinated with the VA to support daily living at home.',
                'heroImage', '/images/services/veteran-care.jpg',
                'bodyHtml', '<p>A Home Health Aide is a trained person who can come to a Veteran''s home and help the Veteran take care of themselves and their daily activities. Home Health Aides are not nurses, but they are supervised by a registered nurse who will help assess the Veteran''s daily living needs.</p>',
                'highlights', JSON_ARRAY('VA-contracted Home Health Aide services', 'Support for isolated Veterans or overburdened caregivers', 'Respite care for family caregivers', 'Available regardless of Veteran''s age'),
                'isFeaturedOnHome', true),
    NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_care_services AND slug = 'veteran-care');

-- ── 6. Entries: blog-posts (from blog.json) ─────────────────────────────────
INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, published_at, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_blog_posts, '10-signs-your-loved-one-needs-home-health-care-chester-sc', '10 Signs Your Loved One Needs Home Health Care in Chester, SC', 'published', 1, '2026-06-01',
    JSON_OBJECT('excerpt', 'Caring for an aging parent or loved one is one of life''s most meaningful responsibilities. Here are 10 signs it might be time for professional support.',
                'coverImage', '/images/blog/10-signs.jpg',
                'bodyHtml', '<p>Caring for an aging parent or loved one is one of life''s most meaningful responsibilities — but it can also be overwhelming. Here are ten important signs that it might be time to consider professional home health care assistance.</p><h3>1. Difficulty with Daily Activities</h3><p>If your loved one is struggling with daily routines like dressing, bathing, grooming, or cooking, it''s often one of the earliest signs that extra help is needed.</p><h3>2. Social Isolation or Depression</h3><p>Loneliness is one of the most common challenges older adults face. Caregivers offer companionship, conversation, and social engagement.</p><h3>3. Frequent Falls or Safety Concerns</h3><p>Falls are among the most common causes of injury for seniors. Our caregivers provide mobility support, home safety checks, and fall-prevention strategies.</p>',
                'author', 'LarCare Services',
                'publishedAt', '2026-06-01',
                'tags', JSON_ARRAY('home care', 'caregiving', 'chester-sc')),
    NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_blog_posts AND slug = '10-signs-your-loved-one-needs-home-health-care-chester-sc');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, published_at, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_blog_posts, 'complete-guide-home-care-services-chester-county', 'The Complete Guide to Home Care Services in Chester County', 'published', 2, '2026-05-15',
    JSON_OBJECT('excerpt', 'Everything you need to know about home care services in Chester County — what they are, how they work, and how to choose the right plan.',
                'coverImage', '/images/blog/complete-guide.jpg',
                'bodyHtml', '<p>Caring for a loved one is one of life''s greatest responsibilities. At LarCare Services in Chester, SC, we understand that every family''s situation is unique. This complete guide walks you through everything you need to know about home care services in Chester County.</p>',
                'author', 'LarCare Services',
                'publishedAt', '2026-05-15',
                'tags', JSON_ARRAY('home care', 'guide', 'chester-county')),
    NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_blog_posts AND slug = 'complete-guide-home-care-services-chester-county');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, published_at, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_blog_posts, 'dementia-care-compassionate-guide-families-caregivers', 'Dementia Care: A Compassionate Guide for Families & Caregivers', 'published', 3, '2026-04-22',
    JSON_OBJECT('excerpt', 'Understand the stages of dementia and how compassionate, structured in-home care supports both patients and families.',
                'coverImage', '/images/blog/dementia-guide.jpg',
                'bodyHtml', '<p>Dementia care requires patience, structure, and specialized training. This guide walks families through what to expect at each stage.</p>',
                'author', 'LarCare Services',
                'publishedAt', '2026-04-22',
                'tags', JSON_ARRAY('dementia', 'alzheimers')),
    NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_blog_posts AND slug = 'dementia-care-compassionate-guide-families-caregivers');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, published_at, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_blog_posts, 'aging-in-place-comfort-independence-dignity', 'Aging in Place: Embracing Comfort, Independence, and Dignity', 'published', 4, '2026-03-10',
    JSON_OBJECT('excerpt', 'Why more seniors are choosing to age in place, and how the right support system makes it possible.',
                'coverImage', '/images/blog/aging-in-place.jpg',
                'bodyHtml', '<p>Aging in place lets seniors stay in the home they love while receiving the professional support they need.</p>',
                'author', 'LarCare Services',
                'publishedAt', '2026-03-10',
                'tags', JSON_ARRAY('aging in place')),
    NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_blog_posts AND slug = 'aging-in-place-comfort-independence-dignity');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, published_at, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_blog_posts, 'elder-care-affordability-guide-families', 'Elder Care Affordability: A Compassionate Guide for Families', 'published', 5, '2026-02-18',
    JSON_OBJECT('excerpt', 'Understanding the true cost of elder care and the financial options available to families in South Carolina.',
                'coverImage', '/images/blog/affordability.jpg',
                'bodyHtml', '<p>The cost of care depends on the level of assistance required, frequency of visits, and type of care needed.</p>',
                'author', 'LarCare Services',
                'publishedAt', '2026-02-18',
                'tags', JSON_ARRAY('affordability', 'cost')),
    NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_blog_posts AND slug = 'elder-care-affordability-guide-families');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, published_at, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_blog_posts, 'medicare-home-care-compassionate-guide-families', 'Medicare Home Care: A Compassionate Guide for Families', 'published', 6, '2026-01-29',
    JSON_OBJECT('excerpt', 'A breakdown of what Medicare does and doesn''t cover for in-home care, and how to plan around it.',
                'coverImage', '/images/blog/medicare-guide.jpg',
                'bodyHtml', '<p>Understanding Medicare''s coverage of home care services can help families plan and budget with confidence.</p>',
                'author', 'LarCare Services',
                'publishedAt', '2026-01-29',
                'tags', JSON_ARRAY('medicare')),
    NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_blog_posts AND slug = 'medicare-home-care-compassionate-guide-families');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, published_at, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_blog_posts, 'understanding-medicaid-support-services-caring-guide', 'Understanding Medicaid Support Services: A Caring Guide for Families', 'published', 7, '2026-01-05',
    JSON_OBJECT('excerpt', 'How Medicaid waivers and support programs can help offset the cost of home care.',
                'coverImage', '/images/blog/medicaid-guide.jpg',
                'bodyHtml', '<p>Medicaid support services vary by state — here''s what South Carolina families should know.</p>',
                'author', 'LarCare Services',
                'publishedAt', '2026-01-05',
                'tags', JSON_ARRAY('medicaid')),
    NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_blog_posts AND slug = 'understanding-medicaid-support-services-caring-guide');

-- ── 7. Entries: products (from products.json) ───────────────────────────────
INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_products, 'standard-wheelchair', 'Standard Folding Wheelchair', 'published', 1,
    JSON_OBJECT('description', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent at metus ac risus vehicula consectetur.',
                'image', '/images/products/wheelchair.jpg', 'price', 99.99, 'externalUrl', 'https://example-partner-store.com/products/standard-wheelchair'),
    NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_products AND slug = 'standard-wheelchair');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_products, 'infrared-thermometer', 'Touchless Infrared Thermometer', 'published', 2,
    JSON_OBJECT('description', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent at metus ac risus vehicula consectetur.',
                'image', '/images/products/thermometer.jpg', 'price', 99.99, 'externalUrl', 'https://example-partner-store.com/products/infrared-thermometer'),
    NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_products AND slug = 'infrared-thermometer');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_products, 'daily-pill-organizer', '7-Day Pill Organizer', 'published', 3,
    JSON_OBJECT('description', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent at metus ac risus vehicula consectetur.',
                'image', '/images/products/pill-organizer.jpg', 'price', 99.99, 'externalUrl', 'https://example-partner-store.com/products/daily-pill-organizer'),
    NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_products AND slug = 'daily-pill-organizer');

-- ── 8. Entries: testimonials (from misc.json) ───────────────────────────────
-- No natural slug in source data; synthesize stable slugs testimonial-1..4.
INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_testimonials, 'testimonial-1', 'Ayesha Malik', 'published', 1,
    JSON_OBJECT('authorName', 'Ayesha Malik', 'rating', 5, 'quote', 'Customer support was extremely helpful and responsive.'), NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_testimonials AND slug = 'testimonial-1');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_testimonials, 'testimonial-2', 'Ayesha Malik', 'published', 2,
    JSON_OBJECT('authorName', 'Ayesha Malik', 'rating', 5, 'quote', 'Customer support was extremely helpful and responsive.'), NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_testimonials AND slug = 'testimonial-2');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_testimonials, 'testimonial-3', 'Ayesha Malik', 'published', 3,
    JSON_OBJECT('authorName', 'Ayesha Malik', 'rating', 5, 'quote', 'Customer support was extremely helpful and responsive.'), NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_testimonials AND slug = 'testimonial-3');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_testimonials, 'testimonial-4', 'Ayesha Malik', 'published', 4,
    JSON_OBJECT('authorName', 'Ayesha Malik', 'rating', 2, 'quote', 'Customer support was extremely helpful and responsive.'), NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_testimonials AND slug = 'testimonial-4');

-- ── 9. Entries: core-values (from misc.json) ────────────────────────────────
INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_core_values, 'affordable-prices', 'Affordable Prices', 'published', 1,
    JSON_OBJECT('icon', 'dollar', 'description', 'Our prices are competitive and affordable. We aim to work with all budgets and promise to not overextend families financially.'), NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_core_values AND slug = 'affordable-prices');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_core_values, 'experienced-care-experts', 'Experienced Care Experts', 'published', 2,
    JSON_OBJECT('icon', 'sparkle', 'description', 'We have seasoned, licensed, and experienced care agents with over 100 years of combined service caring for families.'), NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_core_values AND slug = 'experienced-care-experts');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_core_values, 'privacy-is-important', 'Privacy is Important', 'published', 3,
    JSON_OBJECT('icon', 'lock', 'description', 'We ensure secure platforms to protect identity and secure processes that protect our families from harmful acts.'), NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_core_values AND slug = 'privacy-is-important');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_core_values, 'dedication-and-loyalty', 'Dedication and Loyalty', 'published', 4,
    JSON_OBJECT('icon', 'heart', 'description', 'Your family becomes our family. Our management team and care experts will care for your family like our own.'), NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_core_values AND slug = 'dedication-and-loyalty');

-- ── 10. Entries: about-stats (from misc.json) ───────────────────────────────
INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_about_stats, 'over-100-years-combined-experience', 'Over 100 years of combined licensed experience', 'published', 1,
    JSON_OBJECT('icon', 'sparkle'), NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_about_stats AND slug = 'over-100-years-combined-experience');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_about_stats, 'certified-care-agents', 'Certified Care Agents', 'published', 2,
    JSON_OBJECT('icon', 'shield'), NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_about_stats AND slug = 'certified-care-agents');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_about_stats, 'custom-care-plan', 'Custom Care Plan', 'published', 3,
    JSON_OBJECT('icon', 'sliders'), NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_about_stats AND slug = 'custom-care-plan');

-- ── 11. Entries: job-openings (from misc.json) ──────────────────────────────
INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_job_openings, 'home-care-services', 'Home Care Services', 'published', 1,
    JSON_OBJECT('type', 'Full-time / Part-time', 'location', 'Chester, SC', 'summary', 'Support clients with non-medical daily living assistance.'), NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_job_openings AND slug = 'home-care-services');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_job_openings, 'personal-care-aide', 'Personal Care Aide', 'published', 2,
    JSON_OBJECT('type', 'Full-time / Part-time', 'location', 'Chester, SC', 'summary', 'Assist with bathing, dressing, grooming, and mobility.'), NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_job_openings AND slug = 'personal-care-aide');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_job_openings, 'companion-care', 'Companion Care', 'published', 3,
    JSON_OBJECT('type', 'Part-time', 'location', 'Chester, SC', 'summary', 'Provide companionship, conversation, and social engagement.'), NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_job_openings AND slug = 'companion-care');

INSERT INTO fact_web_entries (tenant_id, business_id, site_id, collection_id, slug, title, status, sort_order, data_json, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @coll_job_openings, 'certified-nurse-assistant', 'Certified Nurse Assistant (CNA)', 'published', 4,
    JSON_OBJECT('type', 'Full-time', 'location', 'Chester, SC', 'summary', 'Deliver hands-on personal care under RN supervision.'), NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM fact_web_entries WHERE collection_id = @coll_job_openings AND slug = 'certified-nurse-assistant');

-- ── 12. Navigation: header (primaryNav) ──────────────────────────────────────
INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, NULL, 'header', 'Home', '/', 1, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND location = 'header' AND parent_id IS NULL AND label = 'Home');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, NULL, 'header', 'Healthcare', '/healthcare', 2, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND location = 'header' AND parent_id IS NULL AND label = 'Healthcare');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, NULL, 'header', 'About Us', '/about-us', 3, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND location = 'header' AND parent_id IS NULL AND label = 'About Us');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, NULL, 'header', 'Blog', '/blog', 4, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND location = 'header' AND parent_id IS NULL AND label = 'Blog');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, NULL, 'header', 'Products', '/products', 5, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND location = 'header' AND parent_id IS NULL AND label = 'Products');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, NULL, 'header', 'Forms', '/forms', 6, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND location = 'header' AND parent_id IS NULL AND label = 'Forms');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, NULL, 'header', 'Portal', '/portal', 7, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND location = 'header' AND parent_id IS NULL AND label = 'Portal');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, label, href, open_in_new_tab, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, NULL, 'header', 'SamariCare', 'https://samaricare.com', 1, 8, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND location = 'header' AND parent_id IS NULL AND label = 'SamariCare');

-- Healthcare children (parent resolved by label lookup — insert parents first, above)
SET @nav_healthcare := (SELECT id FROM dim_web_navigation_items WHERE site_id = @site_id AND location = 'header' AND parent_id IS NULL AND label = 'Healthcare' LIMIT 1);

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @nav_healthcare, 'header', 'Alzheimer''s Disease and Dementia Care', '/healthcare/alzheimers-dementia-care', 1, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND parent_id = @nav_healthcare AND label = 'Alzheimer''s Disease and Dementia Care');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @nav_healthcare, 'header', 'End-of-Life Care', '/healthcare/end-of-life-care', 2, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND parent_id = @nav_healthcare AND label = 'End-of-Life Care');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @nav_healthcare, 'header', 'Home and Personal Care Services', '/healthcare/home-and-personal-care-services', 3, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND parent_id = @nav_healthcare AND label = 'Home and Personal Care Services');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @nav_healthcare, 'header', 'Veteran Care', '/healthcare/veteran-care', 4, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND parent_id = @nav_healthcare AND label = 'Veteran Care');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @nav_healthcare, 'header', 'Transitional Care', '/healthcare/transitional-care', 5, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND parent_id = @nav_healthcare AND label = 'Transitional Care');

-- About Us children
SET @nav_about_us := (SELECT id FROM dim_web_navigation_items WHERE site_id = @site_id AND location = 'header' AND parent_id IS NULL AND label = 'About Us' LIMIT 1);

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @nav_about_us, 'header', 'About LarCare', '/about-us', 1, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND parent_id = @nav_about_us AND label = 'About LarCare');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @nav_about_us, 'header', 'Careers', '/about-us/careers', 2, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND parent_id = @nav_about_us AND label = 'Careers');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, @nav_about_us, 'header', 'Contact Us', '/about-us/contact-us', 3, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND parent_id = @nav_about_us AND label = 'Contact Us');

-- ── 13. Navigation: footer (footerColumns) ──────────────────────────────────
INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, column_title, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, NULL, 'footer', 'Our company', 'Home', '/', 1, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND location = 'footer' AND column_title = 'Our company' AND label = 'Home');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, column_title, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, NULL, 'footer', 'Our company', 'About LarCare', '/about-us', 2, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND location = 'footer' AND column_title = 'Our company' AND label = 'About LarCare');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, column_title, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, NULL, 'footer', 'Our company', 'Careers', '/about-us/careers', 3, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND location = 'footer' AND column_title = 'Our company' AND label = 'Careers');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, column_title, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, NULL, 'footer', 'Our company', 'Contact Us', '/about-us/contact-us', 4, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND location = 'footer' AND column_title = 'Our company' AND label = 'Contact Us');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, column_title, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, NULL, 'footer', 'Our company', 'Privacy Policy & Terms', '/privacy-policy', 5, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND location = 'footer' AND column_title = 'Our company' AND label = 'Privacy Policy & Terms');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, column_title, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, NULL, 'footer', 'Our company', 'Products', '/products', 6, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND location = 'footer' AND column_title = 'Our company' AND label = 'Products');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, column_title, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, NULL, 'footer', 'Care Services', 'Home and Personal Care Services', '/healthcare/home-and-personal-care-services', 1, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND location = 'footer' AND column_title = 'Care Services' AND label = 'Home and Personal Care Services');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, column_title, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, NULL, 'footer', 'Care Services', 'Veteran Care', '/healthcare/veteran-care', 2, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND location = 'footer' AND column_title = 'Care Services' AND label = 'Veteran Care');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, column_title, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, NULL, 'footer', 'Care Services', 'End-of-Life Care', '/healthcare/end-of-life-care', 3, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND location = 'footer' AND column_title = 'Care Services' AND label = 'End-of-Life Care');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, column_title, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, NULL, 'footer', 'Care Services', 'Transitional Care', '/healthcare/transitional-care', 4, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND location = 'footer' AND column_title = 'Care Services' AND label = 'Transitional Care');

INSERT INTO dim_web_navigation_items (tenant_id, business_id, site_id, parent_id, location, column_title, label, href, sort_order, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, NULL, 'footer', 'Care Services', 'Alzheimer''s Disease and Dementia Care', '/healthcare/alzheimers-dementia-care', 5, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_navigation_items WHERE site_id = @site_id AND location = 'footer' AND column_title = 'Care Services' AND label = 'Alzheimer''s Disease and Dementia Care');

-- ── 14. Forms: contact + sms-consent ─────────────────────────────────────────
-- Table definitions live in web_forms_module.sql (must run before this file).
INSERT INTO dim_web_forms (tenant_id, business_id, site_id, form_key, name, success_message, notify_emails, is_active, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, 'contact', 'Contact Form', 'Thanks for reaching out — a member of our team will get back to you shortly.', 'info@larcareservices.com', 1, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_forms WHERE site_id = @site_id AND form_key = 'contact');

INSERT INTO dim_web_forms (tenant_id, business_id, site_id, form_key, name, success_message, notify_emails, is_active, created_by, updated_by, is_deleted)
SELECT @tenant_id, @business_id, @site_id, 'sms-consent', 'SMS Consent', 'Thank you — your SMS consent preferences have been saved.', 'info@larcareservices.com', 1, NULL, NULL, 0
WHERE NOT EXISTS (SELECT 1 FROM dim_web_forms WHERE site_id = @site_id AND form_key = 'sms-consent');

SET @form_contact := (SELECT id FROM dim_web_forms WHERE site_id = @site_id AND form_key = 'contact' LIMIT 1);
SET @form_sms_consent := (SELECT id FROM dim_web_forms WHERE site_id = @site_id AND form_key = 'sms-consent' LIMIT 1);

-- contact fields: firstName, lastName, email, phone, message (required)
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT @form_contact, 'firstName', 'First Name', 'text', 0, 1
WHERE NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @form_contact AND field_key = 'firstName');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT @form_contact, 'lastName', 'Last Name', 'text', 0, 2
WHERE NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @form_contact AND field_key = 'lastName');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT @form_contact, 'email', 'Email', 'email', 0, 3
WHERE NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @form_contact AND field_key = 'email');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT @form_contact, 'phone', 'Phone', 'tel', 0, 4
WHERE NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @form_contact AND field_key = 'phone');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT @form_contact, 'message', 'Message', 'textarea', 1, 5
WHERE NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @form_contact AND field_key = 'message');

-- sms-consent fields: firstName, lastName, email, phone, consent (all required)
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT @form_sms_consent, 'firstName', 'First Name', 'text', 1, 1
WHERE NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @form_sms_consent AND field_key = 'firstName');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT @form_sms_consent, 'lastName', 'Last Name', 'text', 1, 2
WHERE NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @form_sms_consent AND field_key = 'lastName');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT @form_sms_consent, 'email', 'Email', 'email', 1, 3
WHERE NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @form_sms_consent AND field_key = 'email');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT @form_sms_consent, 'phone', 'Phone', 'tel', 1, 4
WHERE NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @form_sms_consent AND field_key = 'phone');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT @form_sms_consent, 'consent', 'SMS Consent', 'checkbox', 1, 5
WHERE NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @form_sms_consent AND field_key = 'consent');

-- ----- larcare_core_pages_seed.sql -----
-- Recreates the About Us / Contact Us / Portal CMS pages (dim_web_pages) — these existed
-- earlier in the same work but were removed and rebuilt here after a client-charset bug
-- (mysql defaulting to cp850, not utf8mb4) corrupted em-dashes in several other pages;
-- deploy this one with --default-character-set=utf8mb4 same as the others.
-- Idempotent (guarded by slug existence per site), additive only.
-- Deploy: mysql --default-character-set=utf8mb4 -u root -p `stacie_Aggie_v1.0` < db/larcare_core_pages_seed.sql

USE `stacie_Aggie_v1.0`;

SET @site_id = (SELECT id FROM dim_web_sites WHERE primary_domain = 'larcareservices.com' LIMIT 1);
SET @tenant_id = (SELECT tenant_id FROM dim_web_sites WHERE id = @site_id);
SET @business_id = (SELECT business_id FROM dim_web_sites WHERE id = @site_id);

-- ================= About Us =================
INSERT INTO dim_web_pages (tenant_id, business_id, site_id, slug, title, route, status, layout_json, meta_title)
SELECT @tenant_id, @business_id, @site_id, 'about-us', 'About Us', '/about-us', 'published', JSON_OBJECT(
  'version', 1,
  'blocks', JSON_ARRAY(
    JSON_OBJECT('id', 'about-hero', 'type', 'hero', 'props', JSON_OBJECT(
      'heading', 'Larry Puts the Care in LarCare',
      'imageUrl', '/images/hero/about-hero.jpg'
    ), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'about-intro', 'type', 'richText', 'props', JSON_OBJECT('html',
      '<h2 style="text-align:center">Learn More About LarCare</h2><p style="text-align:center">At LarCare, our highly skilled healthcare professionals are dedicated to providing specialized home healthcare services. Our mission is to support our patients'' desire to maintain their independence within the comfort of their own homes. Prioritizing home healthcare, we ensure every aspect of our service reflects our commitment to your health and autonomy.</p>'
    ), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'about-letter', 'type', 'richText', 'props', JSON_OBJECT('html',
      '<p>Dear Valued Patient and Family,</p><p>Thank you for selecting LarCare Inc. as your in-home care provider. We are deeply honored to support your journey towards recovery and wellness. At LarCare, our mission is to meet your healthcare needs through a partnership that delivers an unparalleled care experience.</p><p>You are at the heart of everything we do. Our team, fueled by a passion for home health care, offers a comprehensive suite of services including Skilled Nursing, Physical Therapy, Occupational Therapy, Speech Therapy, Medical Social Work, Private Duty Nursing, and Personal Care Assistance.</p><p>Thank you for entrusting us with your care.</p><p>Warm regards,<br>Larry Talford</p>'
    ), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'about-mission-columns', 'type', 'columns', 'props', JSON_OBJECT('columnCount', 2, 'gapPx', 32), 'children', JSON_ARRAY(
      JSON_OBJECT('id', 'about-mission-text', 'type', 'richText', 'props', JSON_OBJECT('html',
        '<h2>Our Mission</h2><p>Our mission is to empower our clients by delivering personalized in-home healthcare services, fostering independence, and enhancing quality of life through professional and compassionate care. We strive for greatness as the standard for In-Home Healthcare needs.</p>'
      ), 'children', JSON_ARRAY()),
      JSON_OBJECT('id', 'about-mission-image', 'type', 'image', 'props', JSON_OBJECT('url', '/images/hero/mission.jpg', 'altText', 'Our mission'), 'children', JSON_ARRAY())
    ))
  )
), 'About LarCare | LarCare Services'
WHERE @site_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_pages WHERE site_id = @site_id AND slug = 'about-us');

-- ================= Contact Us =================
INSERT INTO dim_web_pages (tenant_id, business_id, site_id, slug, title, route, status, layout_json, meta_title)
SELECT @tenant_id, @business_id, @site_id, 'contact-us', 'Contact Us', '/about-us/contact-us', 'published', JSON_OBJECT(
  'version', 1,
  'blocks', JSON_ARRAY(
    JSON_OBJECT('id', 'contact-hero', 'type', 'hero', 'props', JSON_OBJECT('heading', 'Contact Us', 'imageUrl', '/images/hero/contact-hero.jpg'), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'contact-intro', 'type', 'richText', 'props', JSON_OBJECT('html',
      '<h2>Need assistance?</h2><p>Please complete the contact form.</p>'
    ), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'contact-form', 'type', 'formEmbed', 'props', JSON_OBJECT('formKey', 'contact'), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'contact-image', 'type', 'image', 'props', JSON_OBJECT('url', '/images/hero/contact-team.jpg', 'altText', 'The LarCare team'), 'children', JSON_ARRAY())
  )
), 'Contact Us | LarCare Services'
WHERE @site_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_pages WHERE site_id = @site_id AND slug = 'contact-us');

-- ================= Portal =================
INSERT INTO dim_web_pages (tenant_id, business_id, site_id, slug, title, route, status, layout_json, meta_title)
SELECT @tenant_id, @business_id, @site_id, 'portal', 'Portal', '/portal', 'published', JSON_OBJECT(
  'version', 1,
  'blocks', JSON_ARRAY(
    JSON_OBJECT('id', 'portal-hero', 'type', 'hero', 'props', JSON_OBJECT(
      'heading', 'LarCare Portal',
      'subheading', 'Access your patient or employee portal to view schedules, documents, and care plans.',
      'imageUrl', '/images/hero/portal-hero.jpg'
    ), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'portal-intro', 'type', 'richText', 'props', JSON_OBJECT('html',
      '<p style="text-align:center">The LarCare Portal is provided through a secure third-party system. Click below to sign in with your existing credentials.</p>'
    ), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'portal-cta', 'type', 'button', 'props', JSON_OBJECT('label', 'Go to Portal', 'href', 'https://portal.larcareservices.com', 'variant', 'primary'), 'children', JSON_ARRAY())
  )
), 'Portal | LarCare Services'
WHERE @site_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_pages WHERE site_id = @site_id AND slug = 'portal');

-- ----- larcare_pages_media_seed.sql -----
-- Converts LarCare's remaining hardcoded-JSX pages into real CMS Pages (dim_web_pages),
-- same pattern as About Us / Contact Us / Portal / Privacy Policy: Home, Healthcare index,
-- Careers, Products, and the Forms index. Uses the new serviceGrid/statsGrid/coreValueGrid/
-- jobGrid/trustBadges block types added to BlockRenderer + WEB-THEME-DESIGN-CONTEXT.md
-- alongside this migration, so the previously-dynamic-but-hardcoded-copy grids on these
-- pages are now fully authored in layout_json.
-- Also registers every existing static image under public/images/ as a dim_web_media row,
-- so the Media library actually lists what the site uses (previously 0 rows).
-- Idempotent (guarded by slug/storage_path existence), additive only.
-- Deploy: mysql -u root -p `stacie_Aggie_v1.0` < db/larcare_pages_media_seed.sql

USE `stacie_Aggie_v1.0`;

SET @site_id = (SELECT id FROM dim_web_sites WHERE primary_domain = 'larcareservices.com' LIMIT 1);
SET @tenant_id = (SELECT tenant_id FROM dim_web_sites WHERE id = @site_id);
SET @business_id = (SELECT business_id FROM dim_web_sites WHERE id = @site_id);

-- ================= Home =================
INSERT INTO dim_web_pages (tenant_id, business_id, site_id, slug, title, route, status, layout_json, meta_title)
SELECT @tenant_id, @business_id, @site_id, 'home', 'Home', '/', 'published', JSON_OBJECT(
  'version', 1,
  'blocks', JSON_ARRAY(
    JSON_OBJECT('id', 'home-hero', 'type', 'hero', 'props', JSON_OBJECT(
      'heading', 'Personal home care for every unique situation',
      'subheading', 'We take tender loving care every step of the way. Larry Puts the Care in LarCare',
      'imageUrl', '/images/hero/home-hero.jpg',
      'ctaLabel', 'Contact Us', 'ctaHref', '/about-us/contact-us'
    ), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'home-intro', 'type', 'richText', 'props', JSON_OBJECT('html',
      '<h2>Excellent Senior Care for Extraordinary Families</h2><p>At LarCare, we&#39;re dedicated to enriching the lives of our seniors beyond healthcare. Our personalized care plans include safe transportation to church, BINGO, and more. We ensure a seamless blend of comfort, joy, and independence for you and your loved ones.</p><p>With every detail managed with compassion and professionalism, LarCare stands as your partner in creating an experience that&#39;s not just satisfactory, but truly heartwarming. Your well-being is our mission.</p>'
    ), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'home-services-heading', 'type', 'richText', 'props', JSON_OBJECT('html',
      '<h2>Our LarCare Services</h2><p>Your well-being is our utmost priority. At LarCare, we pledge to offer unwavering support and comprehensive care tailored to meet your unique needs.</p>'
    ), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'home-services-grid', 'type', 'serviceGrid', 'props', JSON_OBJECT('columns', 3, 'featuredOnly', true, 'limit', 3), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'home-services-more', 'type', 'button', 'props', JSON_OBJECT('label', 'View All Services', 'href', '/healthcare', 'variant', 'outline'), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'home-about-heading', 'type', 'richText', 'props', JSON_OBJECT('html',
      '<h2>About LarCare</h2><p>We are a team of highly-skilled healthcare professionals focused on delivering a wide range of specialized home healthcare services to patients who wish to maintain their independence at home.</p>'
    ), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'home-stats-grid', 'type', 'statsGrid', 'props', JSON_OBJECT('columns', 3), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'home-about-more', 'type', 'button', 'props', JSON_OBJECT('label', 'More About Us', 'href', '/about-us', 'variant', 'outline'), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'home-values-heading', 'type', 'richText', 'props', JSON_OBJECT('html',
      '<h2>We are the Heart of Personal Care!</h2><p>LarCare was named after Larry the visionary. Larry Cares is at the heart of all of the core values. LarCare believes in:</p>'
    ), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'home-values-grid', 'type', 'coreValueGrid', 'props', JSON_OBJECT(), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'home-growing', 'type', 'richText', 'props', JSON_OBJECT('html',
      '<h2>We are Growing.</h2><p>We&#39;re expanding the states we serve — need assistance? <a href="/about-us/contact-us">Complete the contact form</a>, or share feedback via our <a href="/forms/patient-satisfaction-survey">Patient Satisfaction Survey</a>.</p>'
    ), 'children', JSON_ARRAY())
  )
), 'LarCare Services | Personal Home Care for Every Unique Situation'
WHERE @site_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_pages WHERE site_id = @site_id AND slug = 'home');

-- ================= Healthcare index =================
INSERT INTO dim_web_pages (tenant_id, business_id, site_id, slug, title, route, status, layout_json, meta_title)
SELECT @tenant_id, @business_id, @site_id, 'healthcare', 'Healthcare Services', '/healthcare', 'published', JSON_OBJECT(
  'version', 1,
  'blocks', JSON_ARRAY(
    JSON_OBJECT('id', 'healthcare-hero', 'type', 'hero', 'props', JSON_OBJECT(
      'heading', 'Personal home care for every unique situation',
      'subheading', 'We take tender loving care every step of the way. Larry Puts the Care in LarCare.',
      'imageUrl', '/images/hero/home-hero.jpg'
    ), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'healthcare-grid', 'type', 'serviceGrid', 'props', JSON_OBJECT('columns', 3), 'children', JSON_ARRAY())
  )
), 'Healthcare Services | LarCare Services'
WHERE @site_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_pages WHERE site_id = @site_id AND slug = 'healthcare');

-- ================= Careers =================
INSERT INTO dim_web_pages (tenant_id, business_id, site_id, slug, title, route, status, layout_json, meta_title)
SELECT @tenant_id, @business_id, @site_id, 'careers', 'Careers', '/about-us/careers', 'published', JSON_OBJECT(
  'version', 1,
  'blocks', JSON_ARRAY(
    JSON_OBJECT('id', 'careers-hero', 'type', 'hero', 'props', JSON_OBJECT(
      'heading', 'Careers: In-Home Care Nursing & Caregiver Jobs',
      'subheading', 'Working in in-home care is more than just a job — it''s a chance to make a real difference in someone''s daily life.',
      'imageUrl', '/images/hero/careers-hero.jpg'
    ), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'careers-heading', 'type', 'richText', 'props', JSON_OBJECT('html',
      '<h2 style="text-align:center">Types of Home Health Care Jobs</h2>'
    ), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'careers-grid', 'type', 'jobGrid', 'props', JSON_OBJECT('columns', 4), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'careers-apply', 'type', 'button', 'props', JSON_OBJECT('label', 'Employment Application', 'href', '/forms', 'variant', 'primary'), 'children', JSON_ARRAY())
  )
), 'Careers | LarCare Services'
WHERE @site_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_pages WHERE site_id = @site_id AND slug = 'careers');

-- ================= Products =================
INSERT INTO dim_web_pages (tenant_id, business_id, site_id, slug, title, route, status, layout_json, meta_title)
SELECT @tenant_id, @business_id, @site_id, 'products', 'Products', '/products', 'published', JSON_OBJECT(
  'version', 1,
  'blocks', JSON_ARRAY(
    JSON_OBJECT('id', 'products-hero', 'type', 'hero', 'props', JSON_OBJECT(
      'heading', 'Discover Products That Fit Your Lifestyle',
      'subheading', 'Everyday essentials for care at home — browse, add to cart, and check out securely.',
      'imageUrl', '/images/hero/products-hero.jpg'
    ), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'products-grid', 'type', 'productGrid', 'props', JSON_OBJECT('columns', 3), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'products-trust', 'type', 'trustBadges', 'props', JSON_OBJECT('badges', JSON_ARRAY(
      JSON_OBJECT('icon', '✅', 'title', '100% High Quality Product', 'text', 'Thoroughly inspected for top performance.'),
      JSON_OBJECT('icon', '🚚', 'title', 'Free Shipping', 'text', 'Fast shipping within 3–5 days.'),
      JSON_OBJECT('icon', '💰', 'title', 'Money Back Guarantee', 'text', '30-day hassle-free refunds.')
    )), 'children', JSON_ARRAY())
  )
), 'Products | LarCare Services'
WHERE @site_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_pages WHERE site_id = @site_id AND slug = 'products');

-- Products page also gets a testimonialGrid block (added after the initial products page
-- insert above was already deployed elsewhere — appended via UPDATE so re-running this file
-- against an environment that already has the page still converges to the same end state).
UPDATE dim_web_pages
SET layout_json = JSON_ARRAY_APPEND(layout_json, '$.blocks', JSON_OBJECT(
  'id', 'products-reviews', 'type', 'testimonialGrid', 'props', JSON_OBJECT(), 'children', JSON_ARRAY()
))
WHERE site_id = @site_id AND slug = 'products'
  AND NOT JSON_CONTAINS(JSON_EXTRACT(layout_json, '$.blocks[*].type'), '"testimonialGrid"');

-- ================= Forms index =================
INSERT INTO dim_web_pages (tenant_id, business_id, site_id, slug, title, route, status, layout_json, meta_title)
SELECT @tenant_id, @business_id, @site_id, 'forms', 'Forms', '/forms', 'published', JSON_OBJECT(
  'version', 1,
  'blocks', JSON_ARRAY(
    JSON_OBJECT('id', 'forms-hero', 'type', 'hero', 'props', JSON_OBJECT('heading', 'Forms', 'imageUrl', '/images/hero/forms-hero.jpg'), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'forms-employment-links', 'type', 'richText', 'props', JSON_OBJECT('html',
      '<h2>Employment Application</h2><ul><li><a href="/forms/employment-application">Employment Application</a></li><li><a href="/forms/employment-direct-deposit">Employment Direct Deposit</a></li><li><a href="/forms/employment-w9">Employment W9</a></li></ul><h2>Patient Satisfaction &amp; Outcomes Survey</h2><ul><li><a href="/forms/patient-satisfaction-survey">Patient Satisfaction &amp; Outcomes Survey</a></li></ul>'
    ), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'forms-sms-heading', 'type', 'richText', 'props', JSON_OBJECT('html',
      '<h2 style="text-align:center">SMS Consent</h2><p style="text-align:center">Consent to receive SMS updates from LarCare related to customer support, service updates, and occasional marketing communications.</p>'
    ), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'forms-sms-embed', 'type', 'formEmbed', 'props', JSON_OBJECT('formKey', 'sms-consent'), 'children', JSON_ARRAY())
  )
), 'Forms | LarCare Services'
WHERE @site_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_pages WHERE site_id = @site_id AND slug = 'forms');

-- ================= Media library backfill =================
-- Registers every static image the site actually uses, so the Media tab reflects reality
-- instead of showing 0 rows. Images stay served as Next.js static /public assets either
-- way (storage_path mirrors the public URL rather than a real uploaded-file path) — this
-- is a catalog of what's in use, not a migration of file storage.
INSERT INTO dim_web_media (tenant_id, business_id, site_id, storage_path, public_url, original_filename, mime_type)
SELECT * FROM (SELECT t.a, t.b, t.c, t.d, t.e, t.f, t.g FROM (SELECT
  @tenant_id AS a, @business_id AS b, @site_id AS c,
  path_and_name.path AS d, path_and_name.path AS e, path_and_name.name AS f, 'image/jpeg' AS g
  FROM (
    SELECT '/images/hero/home-hero.jpg' AS path, 'home-hero.jpg' AS name UNION ALL
    SELECT '/images/hero/about-hero.jpg', 'about-hero.jpg' UNION ALL
    SELECT '/images/hero/careers-hero.jpg', 'careers-hero.jpg' UNION ALL
    SELECT '/images/hero/contact-hero.jpg', 'contact-hero.jpg' UNION ALL
    SELECT '/images/hero/contact-team.jpg', 'contact-team.jpg' UNION ALL
    SELECT '/images/hero/forms-hero.jpg', 'forms-hero.jpg' UNION ALL
    SELECT '/images/hero/forms-team.jpg', 'forms-team.jpg' UNION ALL
    SELECT '/images/hero/growing.jpg', 'growing.jpg' UNION ALL
    SELECT '/images/hero/mission.jpg', 'mission.jpg' UNION ALL
    SELECT '/images/hero/portal-hero.jpg', 'portal-hero.jpg' UNION ALL
    SELECT '/images/hero/products-hero.jpg', 'products-hero.jpg' UNION ALL
    SELECT '/images/products/pill-organizer.jpg', 'pill-organizer.jpg' UNION ALL
    SELECT '/images/products/thermometer.jpg', 'thermometer.jpg' UNION ALL
    SELECT '/images/products/wheelchair.jpg', 'wheelchair.jpg' UNION ALL
    SELECT '/images/services/dementia-care.jpg', 'dementia-care.jpg' UNION ALL
    SELECT '/images/services/end-of-life-care.jpg', 'end-of-life-care.jpg' UNION ALL
    SELECT '/images/services/home-personal-care.jpg', 'home-personal-care.jpg' UNION ALL
    SELECT '/images/services/transitional-care.jpg', 'transitional-care.jpg' UNION ALL
    SELECT '/images/services/veteran-care.jpg', 'veteran-care.jpg' UNION ALL
    SELECT '/images/blog/10-signs.jpg', '10-signs.jpg' UNION ALL
    SELECT '/images/blog/affordability.jpg', 'affordability.jpg' UNION ALL
    SELECT '/images/blog/aging-in-place.jpg', 'aging-in-place.jpg' UNION ALL
    SELECT '/images/blog/complete-guide.jpg', 'complete-guide.jpg' UNION ALL
    SELECT '/images/blog/dementia-guide.jpg', 'dementia-guide.jpg' UNION ALL
    SELECT '/images/blog/medicaid-guide.jpg', 'medicaid-guide.jpg' UNION ALL
    SELECT '/images/blog/medicare-guide.jpg', 'medicare-guide.jpg'
  ) AS path_and_name
) AS t) AS dedup
WHERE @site_id IS NOT NULL
  AND NOT EXISTS (
    SELECT 1 FROM dim_web_media m WHERE m.site_id = @site_id AND m.storage_path = dedup.d
  );

-- ----- larcare_privacy_policy_seed.sql -----
-- Fixes a dead footer link: dim_web_navigation_items already links to /privacy-policy
-- (footer "Our company" column) but no dim_web_pages row — and no frontend route —
-- ever existed for it, so it 404'd. Adds the page as a static richText block tree,
-- the same pattern used for About Us / Contact Us / Portal.
-- Idempotent (guarded by slug existence per site), additive only.
-- Deploy: mysql -u root -p `stacie_Aggie_v1.0` < db/larcare_privacy_policy_seed.sql

USE `stacie_Aggie_v1.0`;

SET @site_id = (SELECT id FROM dim_web_sites WHERE primary_domain = 'larcareservices.com' LIMIT 1);
SET @tenant_id = (SELECT tenant_id FROM dim_web_sites WHERE id = @site_id);
SET @business_id = (SELECT business_id FROM dim_web_sites WHERE id = @site_id);

INSERT INTO dim_web_pages (tenant_id, business_id, site_id, slug, title, route, status, layout_json, meta_title)
SELECT @tenant_id, @business_id, @site_id, 'privacy-policy', 'Privacy Policy & Terms', '/privacy-policy', 'published', JSON_OBJECT(
  'version', 1,
  'blocks', JSON_ARRAY(
    JSON_OBJECT('id', 'pp-hero', 'type', 'hero', 'props', JSON_OBJECT(
      'heading', 'Privacy Policy & Terms'
    ), 'children', JSON_ARRAY()),
    JSON_OBJECT('id', 'pp-body', 'type', 'richText', 'props', JSON_OBJECT(
      'html', CONCAT(
        '<p><em>Last updated: ', DATE_FORMAT(CURDATE(), '%M %e, %Y'), '</em></p>',
        '<h2>Information We Collect</h2>',
        '<p>When you use this website — including our contact form, employment application, SMS consent form, and satisfaction survey — we collect the information you submit directly, such as your name, email address, phone number, and any message or details you provide. We do not sell this information to third parties.</p>',
        '<h2>Protected Health Information</h2>',
        '<p>Any health-related information you share with LarCare Services as part of receiving care is handled in accordance with applicable healthcare privacy regulations (including HIPAA) and is never used for marketing purposes without your explicit consent.</p>',
        '<h2>How We Use Your Information</h2>',
        '<p>We use the information you provide to respond to inquiries, process employment applications, schedule and coordinate care, send SMS updates you have opted into, and improve our services based on survey feedback.</p>',
        '<h2>SMS Communications</h2>',
        '<p>If you opt in to SMS updates, you may receive messages related to customer support, service updates, and occasional marketing communications. Message and data rates may apply. You may opt out at any time by replying STOP.</p>',
        '<h2>Third-Party Services</h2>',
        '<p>Our patient and employee Portal is provided through a secure third-party system. Use of the Portal is subject to that provider&#39;s own privacy policy and terms in addition to this policy.</p>',
        '<h2>Your Rights</h2>',
        '<p>You may request access to, correction of, or deletion of your personal information at any time by contacting us using the details below.</p>',
        '<h2>Contact Us</h2>',
        '<p>If you have questions about this Privacy Policy or our Terms, please reach out via our <a href="/about-us/contact-us">Contact Us</a> page or call us directly.</p>'
      )
    ), 'children', JSON_ARRAY())
  )
), 'Privacy Policy & Terms | LarCare Services'
WHERE @site_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_pages WHERE site_id = @site_id AND slug = 'privacy-policy');

-- ----- larcare_forms_seed.sql -----
-- Adds the 4 form definitions LarCare's /forms page already links to but that never had
-- a dim_web_forms row: Employment Application, Employment Direct Deposit, Employment W9,
-- and the Patient Satisfaction & Outcomes Survey. The generic public form endpoints
-- (GET/POST /api/public/web/forms/{formKey}) already handle any form_key present here —
-- no backend code change needed, this is purely data.
-- Idempotent (guarded by form_key existence per site), additive only.
-- Deploy: mysql -u root -p `stacie_Aggie_v1.0` < db/larcare_forms_seed.sql

USE `stacie_Aggie_v1.0`;

-- LarCare Services is the only site in this tenant today; resolve it by domain so this
-- migration doesn't hardcode an id that could differ across environments.
SET @larcare_site_id = (SELECT id FROM dim_web_sites WHERE primary_domain = 'larcareservices.com' LIMIT 1);
SET @larcare_tenant_id = (SELECT tenant_id FROM dim_web_sites WHERE id = @larcare_site_id);
SET @larcare_business_id = (SELECT business_id FROM dim_web_sites WHERE id = @larcare_site_id);

-- ---- Employment Application ----
INSERT INTO dim_web_forms (tenant_id, business_id, site_id, form_key, name, success_message, is_active)
SELECT @larcare_tenant_id, @larcare_business_id, @larcare_site_id, 'employment-application',
       'Employment Application', 'Thanks for applying — our hiring team will review your application and be in touch.', 1
WHERE @larcare_site_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_forms WHERE site_id = @larcare_site_id AND form_key = 'employment-application');

SET @employment_application_id = (SELECT id FROM dim_web_forms WHERE site_id = @larcare_site_id AND form_key = 'employment-application');

INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @employment_application_id AS a, 'firstName' AS b, 'First Name' AS c, 'text' AS d, 1 AS e, 1 AS f) AS t
WHERE @employment_application_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @employment_application_id AND field_key = 'firstName');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @employment_application_id AS a, 'lastName' AS b, 'Last Name' AS c, 'text' AS d, 1 AS e, 2 AS f) AS t
WHERE @employment_application_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @employment_application_id AND field_key = 'lastName');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @employment_application_id AS a, 'email' AS b, 'Email' AS c, 'email' AS d, 1 AS e, 3 AS f) AS t
WHERE @employment_application_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @employment_application_id AND field_key = 'email');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @employment_application_id AS a, 'phone' AS b, 'Phone' AS c, 'tel' AS d, 1 AS e, 4 AS f) AS t
WHERE @employment_application_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @employment_application_id AND field_key = 'phone');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @employment_application_id AS a, 'positionAppliedFor' AS b, 'Position Applied For' AS c, 'text' AS d, 1 AS e, 5 AS f) AS t
WHERE @employment_application_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @employment_application_id AND field_key = 'positionAppliedFor');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @employment_application_id AS a, 'availableStartDate' AS b, 'Available Start Date' AS c, 'date' AS d, 1 AS e, 6 AS f) AS t
WHERE @employment_application_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @employment_application_id AND field_key = 'availableStartDate');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @employment_application_id AS a, 'message' AS b, 'Relevant Experience / Cover Message' AS c, 'textarea' AS d, 1 AS e, 7 AS f) AS t
WHERE @employment_application_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @employment_application_id AND field_key = 'message');

-- ---- Employment Direct Deposit ----
INSERT INTO dim_web_forms (tenant_id, business_id, site_id, form_key, name, success_message, is_active)
SELECT @larcare_tenant_id, @larcare_business_id, @larcare_site_id, 'employment-direct-deposit',
       'Employment Direct Deposit', 'Thanks — your direct deposit details have been submitted to payroll.', 1
WHERE @larcare_site_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_forms WHERE site_id = @larcare_site_id AND form_key = 'employment-direct-deposit');

SET @direct_deposit_id = (SELECT id FROM dim_web_forms WHERE site_id = @larcare_site_id AND form_key = 'employment-direct-deposit');

INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @direct_deposit_id AS a, 'employeeName' AS b, 'Employee Name' AS c, 'text' AS d, 1 AS e, 1 AS f) AS t
WHERE @direct_deposit_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @direct_deposit_id AND field_key = 'employeeName');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @direct_deposit_id AS a, 'email' AS b, 'Email' AS c, 'email' AS d, 1 AS e, 2 AS f) AS t
WHERE @direct_deposit_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @direct_deposit_id AND field_key = 'email');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @direct_deposit_id AS a, 'bankName' AS b, 'Bank Name' AS c, 'text' AS d, 1 AS e, 3 AS f) AS t
WHERE @direct_deposit_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @direct_deposit_id AND field_key = 'bankName');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @direct_deposit_id AS a, 'routingNumber' AS b, 'Routing Number' AS c, 'text' AS d, 1 AS e, 4 AS f) AS t
WHERE @direct_deposit_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @direct_deposit_id AND field_key = 'routingNumber');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @direct_deposit_id AS a, 'accountNumber' AS b, 'Account Number' AS c, 'text' AS d, 1 AS e, 5 AS f) AS t
WHERE @direct_deposit_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @direct_deposit_id AND field_key = 'accountNumber');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @direct_deposit_id AS a, 'accountType' AS b, 'Account Type (Checking or Savings)' AS c, 'text' AS d, 1 AS e, 6 AS f) AS t
WHERE @direct_deposit_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @direct_deposit_id AND field_key = 'accountType');

-- ---- Employment W9 ----
INSERT INTO dim_web_forms (tenant_id, business_id, site_id, form_key, name, success_message, is_active)
SELECT @larcare_tenant_id, @larcare_business_id, @larcare_site_id, 'employment-w9',
       'Employment W9', 'Thanks — your W9 information has been submitted.', 1
WHERE @larcare_site_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_forms WHERE site_id = @larcare_site_id AND form_key = 'employment-w9');

SET @w9_id = (SELECT id FROM dim_web_forms WHERE site_id = @larcare_site_id AND form_key = 'employment-w9');

INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @w9_id AS a, 'legalName' AS b, 'Legal Name' AS c, 'text' AS d, 1 AS e, 1 AS f) AS t
WHERE @w9_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @w9_id AND field_key = 'legalName');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @w9_id AS a, 'businessName' AS b, 'Business Name (if applicable)' AS c, 'text' AS d, 0 AS e, 2 AS f) AS t
WHERE @w9_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @w9_id AND field_key = 'businessName');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @w9_id AS a, 'email' AS b, 'Email' AS c, 'email' AS d, 1 AS e, 3 AS f) AS t
WHERE @w9_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @w9_id AND field_key = 'email');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @w9_id AS a, 'taxClassification' AS b, 'Federal Tax Classification' AS c, 'text' AS d, 1 AS e, 4 AS f) AS t
WHERE @w9_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @w9_id AND field_key = 'taxClassification');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @w9_id AS a, 'address' AS b, 'Address (Number, Street, City, State, ZIP)' AS c, 'textarea' AS d, 1 AS e, 5 AS f) AS t
WHERE @w9_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @w9_id AND field_key = 'address');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @w9_id AS a, 'taxIdNumber' AS b, 'SSN or EIN' AS c, 'text' AS d, 1 AS e, 6 AS f) AS t
WHERE @w9_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @w9_id AND field_key = 'taxIdNumber');

-- ---- Patient Satisfaction & Outcomes Survey ----
INSERT INTO dim_web_forms (tenant_id, business_id, site_id, form_key, name, success_message, is_active)
SELECT @larcare_tenant_id, @larcare_business_id, @larcare_site_id, 'patient-satisfaction-survey',
       'Patient Satisfaction & Outcomes Survey', 'Thank you for your feedback — it helps us improve the care we provide.', 1
WHERE @larcare_site_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_forms WHERE site_id = @larcare_site_id AND form_key = 'patient-satisfaction-survey');

SET @survey_id = (SELECT id FROM dim_web_forms WHERE site_id = @larcare_site_id AND form_key = 'patient-satisfaction-survey');

INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @survey_id AS a, 'patientName' AS b, 'Patient Name (optional)' AS c, 'text' AS d, 0 AS e, 1 AS f) AS t
WHERE @survey_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @survey_id AND field_key = 'patientName');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @survey_id AS a, 'careDate' AS b, 'Date of Care Received' AS c, 'date' AS d, 1 AS e, 2 AS f) AS t
WHERE @survey_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @survey_id AND field_key = 'careDate');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @survey_id AS a, 'satisfactionRating' AS b, 'Overall Satisfaction (1-5)' AS c, 'number' AS d, 1 AS e, 3 AS f) AS t
WHERE @survey_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @survey_id AND field_key = 'satisfactionRating');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @survey_id AS a, 'wouldRecommend' AS b, 'I would recommend LarCare to others' AS c, 'checkbox' AS d, 0 AS e, 4 AS f) AS t
WHERE @survey_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @survey_id AND field_key = 'wouldRecommend');
INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @survey_id AS a, 'comments' AS b, 'Additional Comments' AS c, 'textarea' AS d, 0 AS e, 5 AS f) AS t
WHERE @survey_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @survey_id AND field_key = 'comments');

-- ----- larcare_contact_form_file_field.sql -----
-- Adds the "Upload Personal Care Agreement" file field to LarCare's existing Contact Form,
-- closing the gap vs the original WordPress site's contact-us-2 form. Backend support for a
-- multipart submission with a file part already exists generically (PublicWebController's
-- consumes=MULTIPART_FORM_DATA_VALUE overload + AttachableEntityType.WEB_FORM_SUBMISSION) —
-- this migration only adds the field definition, no schema change needed.
-- Idempotent (guarded by field_key existence), additive only.
-- Deploy: mysql -u root -p `stacie_Aggie_v1.0` < db/larcare_contact_form_file_field.sql

USE `stacie_Aggie_v1.0`;

SET @contact_form_id = (
  SELECT f.id FROM dim_web_forms f
  JOIN dim_web_sites s ON f.site_id = s.id
  WHERE s.primary_domain = 'larcareservices.com' AND f.form_key = 'contact'
  LIMIT 1
);

INSERT INTO dim_web_form_fields (form_id, field_key, label, field_type, is_required, sort_order)
SELECT * FROM (SELECT @contact_form_id AS a, 'personalCareAgreement' AS b, 'Upload Personal Care Agreement (optional)' AS c, 'file' AS d, 0 AS e, 5 AS f) AS t
WHERE @contact_form_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_form_fields WHERE form_id = @contact_form_id AND field_key = 'personalCareAgreement');

-- ----- larcare_store_products_seed.sql -----
-- Seeds 3 real Web Store products (dim_web_products + one default variant each) for LarCare,
-- closing the "no cart/checkout, Lorem ipsum copy" gap on /products. This is the SAME cart +
-- Stripe Checkout Session infrastructure already built generically for any ABOS storefront
-- (PublicWebController /store/products, /store/cart, /store/cart/{token}/checkout) — LarCare
-- just never had any dim_web_products rows before this. Also replaces the placeholder
-- Lorem-ipsum descriptions on the old affiliate-link "products" collection entries, in case
-- anything still reads from there.
-- Idempotent (guarded by slug existence per site), additive only.
-- Deploy: mysql -u root -p `stacie_Aggie_v1.0` < db/larcare_store_products_seed.sql

USE `stacie_Aggie_v1.0`;

SET @site_id = (SELECT id FROM dim_web_sites WHERE primary_domain = 'larcareservices.com' LIMIT 1);
SET @tenant_id = (SELECT tenant_id FROM dim_web_sites WHERE id = @site_id);
SET @business_id = (SELECT business_id FROM dim_web_sites WHERE id = @site_id);

-- ---- Standard Folding Wheelchair ----
INSERT INTO dim_web_products (tenant_id, business_id, site_id, slug, name, description, images_json, status, category_key, currency, base_price, product_type, sort_order)
SELECT @tenant_id, @business_id, @site_id, 'standard-wheelchair', 'Standard Folding Wheelchair',
  'Lightweight, foldable wheelchair suited for everyday indoor and outdoor use — durable steel frame, padded armrests, and swing-away footrests for easy transfers.',
  JSON_ARRAY('/images/products/wheelchair.jpg'), 'active', 'mobility-aids', 'USD', 249.99, 'PHYSICAL', 1
WHERE @site_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_products WHERE site_id = @site_id AND slug = 'standard-wheelchair');

SET @wheelchair_id = (SELECT id FROM dim_web_products WHERE site_id = @site_id AND slug = 'standard-wheelchair');

INSERT INTO dim_web_product_variants (tenant_id, business_id, site_id, product_id, sku, price, stock_qty, is_default, sort_order)
SELECT * FROM (SELECT @tenant_id AS a, @business_id AS b, @site_id AS c, @wheelchair_id AS d, 'WHEELCHAIR-STD' AS e, 249.99 AS f, 40 AS g, 1 AS h, 1 AS i) AS t
WHERE @wheelchair_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_product_variants WHERE product_id = @wheelchair_id AND sku = 'WHEELCHAIR-STD');

-- ---- Touchless Infrared Thermometer ----
INSERT INTO dim_web_products (tenant_id, business_id, site_id, slug, name, description, images_json, status, category_key, currency, base_price, product_type, sort_order)
SELECT @tenant_id, @business_id, @site_id, 'infrared-thermometer', 'Touchless Infrared Thermometer',
  'Fast, accurate, no-contact temperature readings in one second — ideal for at-home care, with a fever alert light and memory recall for the last 32 readings.',
  JSON_ARRAY('/images/products/thermometer.jpg'), 'active', 'health-monitoring', 'USD', 34.99, 'PHYSICAL', 2
WHERE @site_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_products WHERE site_id = @site_id AND slug = 'infrared-thermometer');

SET @thermometer_id = (SELECT id FROM dim_web_products WHERE site_id = @site_id AND slug = 'infrared-thermometer');

INSERT INTO dim_web_product_variants (tenant_id, business_id, site_id, product_id, sku, price, stock_qty, is_default, sort_order)
SELECT * FROM (SELECT @tenant_id AS a, @business_id AS b, @site_id AS c, @thermometer_id AS d, 'THERMOMETER-IR' AS e, 34.99 AS f, 120 AS g, 1 AS h, 1 AS i) AS t
WHERE @thermometer_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_product_variants WHERE product_id = @thermometer_id AND sku = 'THERMOMETER-IR');

-- ---- 7-Day Pill Organizer ----
INSERT INTO dim_web_products (tenant_id, business_id, site_id, slug, name, description, images_json, status, category_key, currency, base_price, product_type, sort_order)
SELECT @tenant_id, @business_id, @site_id, 'daily-pill-organizer', '7-Day Pill Organizer',
  'Easy-to-read weekly organizer with AM/PM compartments for every day of the week — large, labeled lids and a compact case that travels well.',
  JSON_ARRAY('/images/products/pill-organizer.jpg'), 'active', 'daily-living-aids', 'USD', 12.99, 'PHYSICAL', 3
WHERE @site_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_products WHERE site_id = @site_id AND slug = 'daily-pill-organizer');

SET @pillorg_id = (SELECT id FROM dim_web_products WHERE site_id = @site_id AND slug = 'daily-pill-organizer');

INSERT INTO dim_web_product_variants (tenant_id, business_id, site_id, product_id, sku, price, stock_qty, is_default, sort_order)
SELECT * FROM (SELECT @tenant_id AS a, @business_id AS b, @site_id AS c, @pillorg_id AS d, 'PILLORG-7DAY' AS e, 12.99 AS f, 200 AS g, 1 AS h, 1 AS i) AS t
WHERE @pillorg_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM dim_web_product_variants WHERE product_id = @pillorg_id AND sku = 'PILLORG-7DAY');

-- ---- Replace Lorem-ipsum copy on the old affiliate-link collection entries ----
UPDATE fact_web_entries e
JOIN dim_web_collections c ON e.collection_id = c.id
SET e.data_json = JSON_SET(e.data_json, '$.description',
  'Lightweight, foldable wheelchair suited for everyday indoor and outdoor use.')
WHERE c.collection_key = 'products' AND e.site_id = @site_id AND e.slug = 'standard-wheelchair';

UPDATE fact_web_entries e
JOIN dim_web_collections c ON e.collection_id = c.id
SET e.data_json = JSON_SET(e.data_json, '$.description',
  'Fast, accurate, no-contact temperature readings for at-home care.')
WHERE c.collection_key = 'products' AND e.site_id = @site_id AND e.slug = 'infrared-thermometer';

UPDATE fact_web_entries e
JOIN dim_web_collections c ON e.collection_id = c.id
SET e.data_json = JSON_SET(e.data_json, '$.description',
  'Easy-to-read weekly organizer with AM/PM compartments.')
WHERE c.collection_key = 'products' AND e.site_id = @site_id AND e.slug = 'daily-pill-organizer';

-- ----- projects_hierarchy_and_ai.sql -----
-- Projects: type-specific work-item hierarchy (parent_id self-join) + Action Item
-- cross-project linking. Idempotent (information_schema-guarded), same pattern as
-- projects_foundation_refactor.sql.
-- Deploy: mysql -u root -p `stacie_Aggie_v1.0` < db/projects_hierarchy_and_ai.sql

USE `stacie_Aggie_v1.0`;

SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fact_project_tasks' AND COLUMN_NAME = 'parent_id');
SET @sql = IF(@col_exists = 0, 'ALTER TABLE `fact_project_tasks` ADD COLUMN `parent_id` BIGINT NULL', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fact_project_tasks' AND INDEX_NAME = 'idx_fact_project_tasks_parent');
SET @sql = IF(@idx_exists = 0, 'ALTER TABLE `fact_project_tasks` ADD INDEX `idx_fact_project_tasks_parent` (`tenant_id`, `is_deleted`, `parent_id`)', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

-- Normalize any legacy type values to the new vocabulary's closest root type, so old
-- rows don't become orphaned/invalid under the new hierarchy validation.
-- `type` carries no index, so this trips client-side "safe update mode" (Error 1175)
-- even with a WHERE clause present — toggle it off for just this one statement.
SET SQL_SAFE_UPDATES = 0;
UPDATE fact_project_tasks SET type = 'Task' WHERE type IN ('Backlog', 'Epic') AND is_deleted = 0;
SET SQL_SAFE_UPDATES = 1;

CREATE TABLE IF NOT EXISTS fact_action_item_projects (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    action_item_id BIGINT NOT NULL,
    project_id BIGINT NOT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    UNIQUE KEY uq_action_item_project (action_item_id, project_id),
    INDEX idx_action_item_projects_item (tenant_id, action_item_id),
    INDEX idx_action_item_projects_project (tenant_id, project_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ----- fact_audit_logs.sql -----
-- =============================================================================
-- fact_audit_logs.sql — audit trail for the passcode-gated System Logs feature
-- (Settings > System Logs, unlocked with "5421"). Records one row per
-- successful mutating event call (POST/PUT .../event with a 2xx response),
-- written generically by ActivityLoggingFilter for every module that uses the
-- event/webhook convention — see DiagnosticLogService for the companion
-- file-based error/warning/success log (logs/system-activity.json).
-- Safe to re-run: CREATE TABLE IF NOT EXISTS.
-- =============================================================================

CREATE TABLE IF NOT EXISTS fact_audit_logs (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    module VARCHAR(50) NOT NULL,
    action VARCHAR(80) NOT NULL,
    user_id INT NULL,
    user_email VARCHAR(150) NULL,
    summary VARCHAR(300) NOT NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    INDEX idx_audit_tenant_created (tenant_id, created_at),
    INDEX idx_audit_module (tenant_id, module),
    INDEX idx_audit_user (tenant_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ----- fact_links.sql -----
-- ============================================================================================
-- Bookmarked URL links attached to a record (Lead, Deal, Contact, Company, Order, Invoice,
-- Project, Workitem, Wiki page).
-- ============================================================================================
-- Adds fact_links: a polymorphic bookmarked-URL table. entity_id is a plain BIGINT with no FK —
-- same no-FK approach as fact_activities/fact_attachments, since Order has no JPA entity at all
-- (stored-procedure/view-backed only).
--
-- Idempotent — safe to re-run.
-- ============================================================================================

USE `stacie_Aggie_v1.0`;

CREATE TABLE IF NOT EXISTS fact_links (
    id            BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id     INT NOT NULL,
    entity_type   VARCHAR(30) NOT NULL,
    entity_id     BIGINT NOT NULL,
    url           VARCHAR(1000) NOT NULL,
    label         VARCHAR(255) NULL,
    created_at    TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by    INT NULL,
    updated_at    TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by    INT NULL,
    is_deleted    TINYINT NOT NULL DEFAULT 0,
    INDEX idx_fact_links_entity (tenant_id, entity_type, entity_id),
    INDEX idx_fact_links_tenant (tenant_id)
);

-- ----- inventory_module.sql -----
USE `stacie_Aggie_v1.0`;

-- =============================================================================
-- inventory_module.sql — Multi-warehouse Inventory foundation (Phase J).
--
-- Introduces a real stock ledger, replacing the single flat
-- dim_web_product_variants.stock_qty counter with per-warehouse quantities and
-- an immutable movement log — same "movement log is the source of truth,
-- rollup is a cache" discipline already used for money (fact_ledger_entries +
-- dim_wallets.balance).
--
-- dim_inventory_items gives every stockable thing one first-class identity,
-- whether it's a storefront variant (WEB_VARIANT), a back-office product item
-- (PRODUCT_ITEM, dim_product_items — already exists, see full_schema_snapshot.sql),
-- or a standalone inventory-only entry with no catalog record at all
-- (STANDALONE). CRM order line items (Phase K) and storefront order items both
-- resolve to a dim_inventory_items row before touching stock.
--
-- Safe to re-run: CREATE TABLE IF NOT EXISTS throughout, information_schema-
-- guarded ALTER for the new dim_web_products column, and backfill INSERTs are
-- all guarded with WHERE NOT EXISTS so re-running never duplicates rows.
-- =============================================================================

-- ── 1. dim_warehouses ────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS dim_warehouses (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    site_id BIGINT NULL,
    name VARCHAR(255) NOT NULL,
    code VARCHAR(50) NULL,
    address VARCHAR(500) NULL,
    is_default TINYINT NOT NULL DEFAULT 0,
    is_active TINYINT NOT NULL DEFAULT 1,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    INDEX idx_dim_warehouses_tenant (tenant_id),
    INDEX idx_dim_warehouses_business (tenant_id, business_id, is_deleted),
    INDEX idx_dim_warehouses_site (site_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 2. dim_inventory_items ───────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS dim_inventory_items (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    business_id INT NULL,
    sku VARCHAR(100) NULL,
    name VARCHAR(255) NOT NULL,
    item_kind VARCHAR(20) NOT NULL DEFAULT 'STANDALONE',
    linked_variant_id BIGINT NULL,
    linked_product_item_id INT NULL,
    reorder_point INT NOT NULL DEFAULT 0,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by INT NULL,
    is_deleted TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_dim_inventory_items_variant (linked_variant_id),
    INDEX idx_dim_inventory_items_tenant (tenant_id),
    INDEX idx_dim_inventory_items_business (tenant_id, business_id, is_deleted),
    INDEX idx_dim_inventory_items_product_item (linked_product_item_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 3. fact_inventory_stock ──────────────────────────────────────────────────
-- Maintained rollup — quantity_on_hand is only ever changed alongside a
-- fact_inventory_movements row in the same transaction (see InventoryService).
CREATE TABLE IF NOT EXISTS fact_inventory_stock (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    warehouse_id BIGINT NOT NULL,
    inventory_item_id BIGINT NOT NULL,
    quantity_on_hand INT NOT NULL DEFAULT 0,
    quantity_reserved INT NOT NULL DEFAULT 0,
    updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    UNIQUE KEY uq_fact_inventory_stock (warehouse_id, inventory_item_id),
    INDEX idx_fact_inventory_stock_tenant (tenant_id),
    INDEX idx_fact_inventory_stock_item (inventory_item_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 4. fact_inventory_movements ──────────────────────────────────────────────
-- Immutable ledger. reference_type/reference_id optionally point at whatever
-- caused the movement (e.g. 'WEB_ORDER'/fact_web_orders.id,
-- 'CRM_ORDER'/fact_orders.id) — same polymorphic, no-FK approach already used
-- by fact_activities/fact_attachments/fact_links.
CREATE TABLE IF NOT EXISTS fact_inventory_movements (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    warehouse_id BIGINT NOT NULL,
    inventory_item_id BIGINT NOT NULL,
    movement_type VARCHAR(20) NOT NULL,
    quantity_delta INT NOT NULL,
    reference_type VARCHAR(30) NULL,
    reference_id BIGINT NULL,
    occurred_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    notes VARCHAR(500) NULL,
    created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by INT NULL,
    INDEX idx_fact_inventory_movements_tenant (tenant_id),
    INDEX idx_fact_inventory_movements_item (inventory_item_id, occurred_at),
    INDEX idx_fact_inventory_movements_warehouse (warehouse_id, occurred_at),
    INDEX idx_fact_inventory_movements_reference (reference_type, reference_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── 5. dim_web_products.product_type ─────────────────────────────────────────
-- PHYSICAL (default, needs shipping + stock tracking), DIGITAL (no shipping,
-- no stock tracking — unlimited), SERVICE (no shipping, no stock tracking).
SET @db := DATABASE();

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_web_products' AND COLUMN_NAME='product_type'),
  'ALTER TABLE dim_web_products ADD COLUMN product_type VARCHAR(20) NOT NULL DEFAULT ''PHYSICAL''');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- ── 6. Backfill — one Main Warehouse per business/site combo already selling
--       physical variants, one inventory item per existing variant, one stock
--       row seeded from that variant's current stock_qty. ─────────────────────
INSERT INTO dim_warehouses (tenant_id, business_id, site_id, name, code, is_default, is_active, created_at, updated_at)
SELECT DISTINCT v.tenant_id, v.business_id, v.site_id, 'Main Warehouse', 'MAIN', 1, 1, NOW(), NOW()
FROM dim_web_product_variants v
WHERE v.is_deleted = 0
  AND NOT EXISTS (
    SELECT 1 FROM dim_warehouses w
    WHERE w.tenant_id = v.tenant_id
      AND w.site_id <=> v.site_id
      AND w.is_default = 1
  );

INSERT INTO dim_inventory_items (tenant_id, business_id, sku, name, item_kind, linked_variant_id, created_at, updated_at)
SELECT v.tenant_id, v.business_id, v.sku, CONCAT('Variant #', v.id), 'WEB_VARIANT', v.id, NOW(), NOW()
FROM dim_web_product_variants v
WHERE v.is_deleted = 0
  AND NOT EXISTS (SELECT 1 FROM dim_inventory_items i WHERE i.linked_variant_id = v.id);

INSERT INTO fact_inventory_stock (tenant_id, warehouse_id, inventory_item_id, quantity_on_hand, updated_at)
SELECT v.tenant_id, w.id, i.id, v.stock_qty, NOW()
FROM dim_web_product_variants v
JOIN dim_inventory_items i ON i.linked_variant_id = v.id
JOIN dim_warehouses w ON w.tenant_id = v.tenant_id AND w.site_id <=> v.site_id AND w.is_default = 1
WHERE v.is_deleted = 0
  AND NOT EXISTS (
    SELECT 1 FROM fact_inventory_stock s WHERE s.warehouse_id = w.id AND s.inventory_item_id = i.id
  );

-- ----- business_types_module.sql -----
USE `stacie_Aggie_v1.0`;

-- =============================================================================
-- business_types_module.sql — Business Type catalog (Phase L1).
--
-- dim_business_types is a lookup table, not a hardcoded Java enum, so a tenant
-- can add its own custom type alongside the system-seeded ones. tenant_id NULL
-- marks a system type (visible to every tenant); a tenant-owned row has
-- tenant_id set. default_active_processes reuses the exact comma-separated
-- module-key vocabulary already stored on businesses.active_processes
-- (CRM, FINANCE, HRM, PROJECT, PROCUREMENT, REAL_ESTATE, LEGAL, WEB) so
-- picking a type can pre-fill that field.
--
-- businesses.business_type_id is nullable — existing businesses are
-- unaffected until an admin explicitly sets a type (see BusinessTemplateSeedService,
-- Phase L3, which reacts to that column being set).
--
-- Safe to re-run: CREATE TABLE IF NOT EXISTS, guarded ALTER, WHERE NOT EXISTS seed guards.
-- =============================================================================

SET @db := DATABASE();

CREATE TABLE IF NOT EXISTS dim_business_types (
    id                       BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id                INT NULL,
    type_key                 VARCHAR(50) NOT NULL,
    name                     VARCHAR(255) NOT NULL,
    description              VARCHAR(500) NULL,
    default_active_processes VARCHAR(255) NULL,
    sort_order               INT NOT NULL DEFAULT 0,
    is_system                TINYINT NOT NULL DEFAULT 0,
    created_at               TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by               INT NULL,
    updated_at               TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by               INT NULL,
    is_deleted               TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_dim_business_types_key (tenant_id, type_key),
    INDEX idx_dim_business_types_tenant (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='businesses' AND COLUMN_NAME='business_type_id'),
  'ALTER TABLE businesses ADD COLUMN business_type_id BIGINT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- ── Seed system business types (tenant_id NULL = visible to every tenant) ────
INSERT INTO dim_business_types (tenant_id, type_key, name, description, default_active_processes, sort_order, is_system, is_deleted)
SELECT NULL, v.type_key, v.name, v.description, v.default_active_processes, v.sort_order, 1, 0
FROM (
    SELECT 'retail' type_key, 'Retail / Merchandise Store' name, 'Physical or online product sales' description, 'CRM,FINANCE,PROCUREMENT,WEB' default_active_processes, 10 sort_order
    UNION ALL SELECT 'service', 'Service Business', 'Consulting, agencies, home-care style services', 'CRM,FINANCE,PROJECT', 20
    UNION ALL SELECT 'healthcare', 'Healthcare / Clinic', 'Patient-facing service business', 'CRM,FINANCE,PROJECT,WEB', 30
    UNION ALL SELECT 'real_estate', 'Real Estate / Property', 'Listings, leases, property-linked deals', 'CRM,FINANCE,REAL_ESTATE', 40
    UNION ALL SELECT 'logistics', 'Logistics', 'Freight, shipment tracking, distribution', 'CRM,FINANCE,PROCUREMENT', 50
    UNION ALL SELECT 'professional_services', 'Professional Services / Agency', 'Agencies, consultancies, freelance studios', 'CRM,FINANCE,PROJECT', 60
    UNION ALL SELECT 'manufacturing', 'Manufacturing', 'Production and assembly businesses', 'CRM,FINANCE,PROCUREMENT,HRM', 70
    UNION ALL SELECT 'hospitality', 'Hospitality / Food Service', 'Restaurants, hotels, food & beverage', 'CRM,FINANCE,PROCUREMENT,HRM', 80
    UNION ALL SELECT 'construction', 'Construction / Trades', 'Contractors, trades, project-based builds', 'CRM,FINANCE,PROJECT,PROCUREMENT,HRM', 90
    UNION ALL SELECT 'general', 'General / Other', 'Anything that does not fit a specific type', 'CRM,FINANCE', 999
) v
WHERE NOT EXISTS (
    SELECT 1 FROM dim_business_types t WHERE t.tenant_id IS NULL AND t.type_key = v.type_key
);

-- ----- business_type_template_catalog.sql -----
USE `stacie_Aggie_v1.0`;

-- =============================================================================
-- business_type_template_catalog.sql — catalog templates per business type (Phase L2).
--
-- Adds a nullable business_type_id to the three existing template tables
-- (dim_deal_templates, dim_workitem_templates, dim_document_templates). A row
-- with business_id IS NULL and business_type_id set is a catalog/starter
-- template for that business type — never used directly by CrmDealFromWebService
-- or TaskBoard (their lookups don't filter on business_type_id), so this is
-- purely additive scaffolding until BusinessTemplateSeedService (Phase L3)
-- clones matching catalog rows into business-owned rows on business creation.
--
-- Safe to re-run: guarded ALTERs, WHERE NOT EXISTS seed guards.
-- =============================================================================

SET @db := DATABASE();

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_deal_templates' AND COLUMN_NAME='business_type_id'),
  'ALTER TABLE dim_deal_templates ADD COLUMN business_type_id BIGINT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_workitem_templates' AND COLUMN_NAME='business_type_id'),
  'ALTER TABLE dim_workitem_templates ADD COLUMN business_type_id BIGINT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='dim_document_templates' AND COLUMN_NAME='business_type_id'),
  'ALTER TABLE dim_document_templates ADD COLUMN business_type_id BIGINT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- dim_deal_templates.template_key is UNIQUE per (tenant_id, template_key) — catalog rows use a
-- 'catalog_<type>_deal' key so they never collide with tenant/business-owned clones (which get a
-- '_biz<id>' suffix at clone time, see BusinessTemplateSeedService).
SET @tenant_id := 1;

INSERT INTO dim_deal_templates (tenant_id, business_id, business_type_id, template_key, name, default_stage, default_status, title_pattern, line_item_type, fulfillment_type, is_deleted)
SELECT @tenant_id, NULL, bt.id, v.template_key, v.name, v.default_stage, v.default_status, v.title_pattern, v.line_item_type, v.fulfillment_type, 0
FROM (
    SELECT 'retail' type_key, 'catalog_retail_deal' template_key, 'Retail Sale' name, 'Negotiation' default_stage, 'Qualified' default_status, 'Sale — {clientName}' title_pattern, 'Product' line_item_type, 'SHIPMENT' fulfillment_type
    UNION ALL SELECT 'service', 'catalog_service_deal', 'Service Engagement', 'Discovery', 'New', 'Service Engagement — {clientName}', 'Service', 'PROJECT'
    UNION ALL SELECT 'healthcare', 'catalog_healthcare_deal', 'Patient Care Plan', 'Discovery', 'New', 'Care Plan — {clientName}', 'Service', 'PROJECT'
    UNION ALL SELECT 'real_estate', 'catalog_real_estate_deal', 'Property Listing', 'Discovery', 'New', 'Listing — {clientName}', 'Service', 'PROJECT'
    UNION ALL SELECT 'logistics', 'catalog_logistics_deal', 'Freight Shipment', 'Discovery', 'New', 'Shipment — {clientName}', 'Service', 'PROJECT'
) v
JOIN dim_business_types bt ON bt.tenant_id IS NULL AND bt.type_key = v.type_key
WHERE NOT EXISTS (
    SELECT 1 FROM dim_deal_templates d WHERE d.tenant_id = @tenant_id AND d.template_key = v.template_key
);

INSERT INTO dim_workitem_templates (tenant_id, business_id, business_type_id, type, name, default_title, default_description, default_priority, is_deleted)
SELECT @tenant_id, NULL, bt.id, v.type, v.name, v.default_title, v.default_description, v.default_priority, 0
FROM (
    SELECT 'healthcare' type_key, 'ToDo' type, 'Patient Intake' name, 'Patient intake — {name}' default_title, 'Collect patient history, insurance, and consent forms.' default_description, 'High' default_priority
    UNION ALL SELECT 'logistics', 'ToDo', 'Shipment Tracking', 'Track shipment — {name}', 'Confirm carrier pickup and monitor delivery milestones.', 'Medium'
) v
JOIN dim_business_types bt ON bt.tenant_id IS NULL AND bt.type_key = v.type_key
WHERE NOT EXISTS (
    SELECT 1 FROM dim_workitem_templates w
    WHERE w.tenant_id = @tenant_id AND w.business_id IS NULL AND w.business_type_id = bt.id AND w.name = v.name
);

-- ----- notifications_module.sql -----
USE `stacie_Aggie_v1.0`;

-- =============================================================================
-- notifications_module.sql — Notifications, Broadcast & Notice Board.
--
-- One shared mechanism backs all three surfaces:
--   fact_notifications        — the notification/announcement/notice itself.
--   fact_notification_targets — its audience (can have multiple rows — e.g.
--                                Role A AND Role B). target_type='TENANT' means
--                                everyone in the tenant (target_id NULL);
--                                'BUSINESS'/'ROLE'/'USER' point at that id.
--   fact_notification_reads   — per-user read state for the bell's unread count.
--
-- Deliberately NOT registered as an AppModule path prefix (see AppModule.java) —
-- gating it behind a subscription plan would silently break the bell for every
-- existing tenant whose plan predates this feature, the same reasoning that
-- already keeps /api/public/web out of AppModule.WEB's prefixes. Authoring
-- (CREATE_NOTIFICATION) is instead gated in NotificationService itself.
--
-- Safe to re-run: CREATE TABLE IF NOT EXISTS throughout.
-- =============================================================================

CREATE TABLE IF NOT EXISTS fact_notifications (
    id            BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id     INT NOT NULL,
    business_id   INT NULL,
    category      VARCHAR(20) NOT NULL DEFAULT 'SYSTEM',
    title         VARCHAR(255) NOT NULL,
    body          TEXT NULL,
    link_url      VARCHAR(500) NULL,
    is_pinned     TINYINT NOT NULL DEFAULT 0,
    pinned_until  TIMESTAMP(6) NULL,
    created_at    TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by    INT NULL,
    is_deleted    TINYINT NOT NULL DEFAULT 0,
    INDEX idx_fact_notifications_tenant (tenant_id, is_deleted, created_at),
    INDEX idx_fact_notifications_pinned (tenant_id, is_pinned, is_deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS fact_notification_targets (
    id              BIGINT AUTO_INCREMENT PRIMARY KEY,
    notification_id BIGINT NOT NULL,
    target_type     VARCHAR(20) NOT NULL,
    target_id       BIGINT NULL,
    INDEX idx_fact_notification_targets_notification (notification_id),
    INDEX idx_fact_notification_targets_lookup (target_type, target_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS fact_notification_reads (
    id              BIGINT AUTO_INCREMENT PRIMARY KEY,
    notification_id BIGINT NOT NULL,
    user_id         INT NOT NULL,
    read_at         TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    UNIQUE KEY uq_fact_notification_reads (notification_id, user_id),
    INDEX idx_fact_notification_reads_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ----- order_project_link.sql -----
-- =============================================================================================
-- order_project_link.sql — lets a CRM Order (fact_orders) point at a Project it's fulfilling.
--
-- fact_orders is entirely stored-procedure/view-backed (no JPA entity) — see orders_sp_views.sql.
-- This adds one nullable back-link column, mirroring dim_projects.opportunity_id's existing
-- back-link convention (see project_deal_link.sql). Deliberately just one column, not a full
-- fulfillment-type system — that broader "Order Types" design (Product/Service/fulfillment_type)
-- is scoped separately and shouldn't be duplicated here.
--
-- Safe to re-run: guarded ALTER, DROP PROCEDURE IF EXISTS + CREATE, CREATE OR REPLACE VIEW.
-- =============================================================================================

USE `stacie_Aggie_v1.0`;

SET @db := DATABASE();

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_orders' AND COLUMN_NAME='linked_project_id'),
  'ALTER TABLE fact_orders ADD COLUMN linked_project_id BIGINT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

CREATE OR REPLACE VIEW vw_orders AS
SELECT
    o.id                        AS id,
    o.id                        AS orderId,
    o.tenant_id                 AS tenant_id,
    o.order_no                  AS orderNo,
    o.client_name               AS clientName,
    o.status                    AS status,
    o.total_amount              AS totalAmount,
    o.total_amount              AS total,
    o.currency                  AS currency,
    o.no_of_items               AS items,
    o.order_date                AS orderDate,
    o.delivery_date             AS deliveryDate,
    o.linked_project_id         AS linkedProjectId,
    o.notes                     AS notes,
    o.created_at                AS createdAt,
    o.updated_at                AS updatedAt
FROM fact_orders o
WHERE o.is_deleted = 0;

DROP PROCEDURE IF EXISTS sp_create_order;
DROP PROCEDURE IF EXISTS sp_update_order;

DELIMITER $$

CREATE PROCEDURE sp_create_order(
    IN  p_tenant_id         INT,
    IN  p_order_no          VARCHAR(100),
    IN  p_client_name       VARCHAR(255),
    IN  p_status            VARCHAR(50),
    IN  p_total_amount      DECIMAL(15,2),
    IN  p_currency          VARCHAR(10),
    IN  p_no_of_items       INT,
    IN  p_order_date        DATE,
    IN  p_delivery_date     DATE,
    IN  p_linked_project_id BIGINT,
    IN  p_notes             TEXT,
    IN  p_created_by        INT,
    OUT p_order_id          BIGINT
)
proc: BEGIN
    IF p_tenant_id IS NULL THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Tenant id is required';
    END IF;
    IF p_order_no IS NULL OR p_order_no = '' THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Order number is required';
    END IF;
    IF p_client_name IS NULL OR p_client_name = '' THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Client name is required';
    END IF;

    INSERT INTO fact_orders (
        tenant_id, order_no, client_name, status, total_amount, currency,
        no_of_items, order_date, delivery_date, linked_project_id, notes,
        created_at, created_by, updated_at, updated_by, is_deleted
    ) VALUES (
        p_tenant_id, p_order_no, p_client_name,
        COALESCE(p_status, 'Pending'),
        COALESCE(p_total_amount, 0),
        COALESCE(p_currency, 'USD'),
        COALESCE(p_no_of_items, 0),
        p_order_date, p_delivery_date, p_linked_project_id, p_notes,
        NOW(), p_created_by, NOW(), p_created_by, 0
    );

    SET p_order_id = LAST_INSERT_ID();
END$$

CREATE PROCEDURE sp_update_order(
    IN  p_tenant_id         INT,
    IN  p_order_id          BIGINT,
    IN  p_order_no          VARCHAR(100),
    IN  p_client_name       VARCHAR(255),
    IN  p_status            VARCHAR(50),
    IN  p_total_amount      DECIMAL(15,2),
    IN  p_currency          VARCHAR(10),
    IN  p_no_of_items       INT,
    IN  p_order_date        DATE,
    IN  p_delivery_date     DATE,
    IN  p_linked_project_id BIGINT,
    IN  p_notes             TEXT,
    IN  p_updated_by        INT
)
proc: BEGIN
    DECLARE v_exists INT;
    SELECT COUNT(*) INTO v_exists FROM fact_orders
        WHERE id = p_order_id AND tenant_id = p_tenant_id AND is_deleted = 0;
    IF v_exists = 0 THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Order not found';
    END IF;

    UPDATE fact_orders SET
        order_no          = COALESCE(p_order_no, order_no),
        client_name       = COALESCE(p_client_name, client_name),
        status            = COALESCE(p_status, status),
        total_amount      = COALESCE(p_total_amount, total_amount),
        currency          = COALESCE(p_currency, currency),
        no_of_items       = COALESCE(p_no_of_items, no_of_items),
        order_date        = COALESCE(p_order_date, order_date),
        delivery_date     = COALESCE(p_delivery_date, delivery_date),
        linked_project_id = COALESCE(p_linked_project_id, linked_project_id),
        notes             = COALESCE(p_notes, notes),
        updated_at        = NOW(),
        updated_by        = p_updated_by
    WHERE id = p_order_id AND tenant_id = p_tenant_id AND is_deleted = 0;
END$$

DELIMITER ;

-- ----- order_types_module.sql -----
USE `stacie_Aggie_v1.0`;

-- =============================================================================
-- order_types_module.sql — Order Type catalog + itemized order line items (Phase K).
--
-- Mirrors dim_business_types' exact pattern (Phase L1): a lookup table, not a hardcoded
-- Java enum, so a tenant can add its own custom order type alongside the system-seeded
-- ones. tenant_id NULL marks a system type (visible to every tenant); a tenant-owned row
-- has tenant_id set.
--
-- requires_line_items drives whether CRMOrders.tsx shows the itemized line-item table
-- (mirroring fact_opportunity_line_items' exact shape/generated-total-price convention —
-- see FactOpportunityLineItem.java) instead of a single free-text total.
--
-- fulfillment_type is informational only here: 'PROJECT' hints the UI to prompt for the
-- existing linked_project_id column (order_project_link.sql); a full Order<->Web Store
-- fulfillment bridge was explicitly deferred in that migration's notes and stays out of
-- scope here too — 'WEB_STORE' is seeded as a value for forward compatibility only.
--
-- fact_orders is entirely stored-procedure/view-backed (no JPA entity) — order_type_id
-- is threaded through sp_create_order/sp_update_order/vw_orders alongside the existing
-- columns. Line items get their own JPA entity + repo, exactly like FactOpportunityLineItem,
-- since they're a child collection naturally suited to delete-then-recreate-on-save.
--
-- Safe to re-run: CREATE TABLE IF NOT EXISTS, guarded ALTER, WHERE NOT EXISTS seed guards,
-- DROP PROCEDURE IF EXISTS + CREATE, CREATE OR REPLACE VIEW.
-- =============================================================================

SET @db := DATABASE();

CREATE TABLE IF NOT EXISTS dim_order_types (
    id                   BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id            INT NULL,
    type_key             VARCHAR(50) NOT NULL,
    name                 VARCHAR(255) NOT NULL,
    description          VARCHAR(500) NULL,
    requires_line_items  TINYINT NOT NULL DEFAULT 0,
    fulfillment_type     VARCHAR(20) NOT NULL DEFAULT 'NONE',
    sort_order           INT NOT NULL DEFAULT 0,
    is_system            TINYINT NOT NULL DEFAULT 0,
    created_at           TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by           INT NULL,
    updated_at           TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by           INT NULL,
    is_deleted           TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_dim_order_types_key (tenant_id, type_key),
    INDEX idx_dim_order_types_tenant (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── Seed system order types (tenant_id NULL = visible to every tenant) ──────────
INSERT INTO dim_order_types (tenant_id, type_key, name, description, requires_line_items, fulfillment_type, sort_order, is_system, is_deleted)
SELECT NULL, v.type_key, v.name, v.description, v.requires_line_items, v.fulfillment_type, v.sort_order, 1, 0
FROM (
    SELECT 'standard' type_key, 'Standard Order' name, 'A simple order with a single total amount' description, 0 requires_line_items, 'NONE' fulfillment_type, 10 sort_order
    UNION ALL SELECT 'product_sale', 'Product Sale', 'Itemized order for one or more physical/digital products', 1, 'NONE', 20
    UNION ALL SELECT 'service_project', 'Service (Project Fulfillment)', 'Itemized service order fulfilled via a linked Project', 1, 'PROJECT', 30
    UNION ALL SELECT 'web_store', 'Web Store Fulfillment', 'Order originating from or fulfilled through the web storefront', 0, 'WEB_STORE', 40
) v
WHERE NOT EXISTS (
    SELECT 1 FROM dim_order_types t WHERE t.tenant_id IS NULL AND t.type_key = v.type_key
);

-- ── fact_orders: add order_type_id ──────────────────────────────────────────────
SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_orders' AND COLUMN_NAME='order_type_id'),
  'ALTER TABLE fact_orders ADD COLUMN order_type_id BIGINT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- ── fact_order_line_items — mirrors fact_opportunity_line_items exactly ─────────
CREATE TABLE IF NOT EXISTS fact_order_line_items (
    id               BIGINT NOT NULL AUTO_INCREMENT,
    tenant_id        INT NOT NULL,
    order_id         BIGINT NOT NULL,
    product_item_id  BIGINT NULL,
    description      VARCHAR(255) NULL,
    quantity         INT NOT NULL DEFAULT 1,
    unit_price       DECIMAL(15,2) NOT NULL DEFAULT 0.00,
    discount_amount  DECIMAL(15,2) DEFAULT 0.00,
    total_price      DECIMAL(15,2) GENERATED ALWAYS AS ((quantity * unit_price) - discount_amount) STORED,
    created_at       TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
    created_by       INT DEFAULT NULL,
    updated_at       TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    updated_by       INT DEFAULT NULL,
    is_deleted       TINYINT(1) DEFAULT '0',
    PRIMARY KEY (id),
    KEY idx_fact_order_line_items_tenant (tenant_id),
    KEY idx_fact_order_line_items_order (order_id),
    KEY idx_fact_order_line_items_product (product_item_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── vw_orders: expose orderTypeId alongside the existing columns ────────────────
CREATE OR REPLACE VIEW vw_orders AS
SELECT
    o.id                        AS id,
    o.id                        AS orderId,
    o.tenant_id                 AS tenant_id,
    o.order_no                  AS orderNo,
    o.client_name               AS clientName,
    o.status                    AS status,
    o.total_amount              AS totalAmount,
    o.total_amount              AS total,
    o.currency                  AS currency,
    o.no_of_items               AS items,
    o.order_date                AS orderDate,
    o.delivery_date             AS deliveryDate,
    o.linked_project_id         AS linkedProjectId,
    o.order_type_id             AS orderTypeId,
    o.notes                     AS notes,
    o.created_at                AS createdAt,
    o.updated_at                AS updatedAt
FROM fact_orders o
WHERE o.is_deleted = 0;

DROP PROCEDURE IF EXISTS sp_create_order;
DROP PROCEDURE IF EXISTS sp_update_order;

DELIMITER $$

CREATE PROCEDURE sp_create_order(
    IN  p_tenant_id         INT,
    IN  p_order_no          VARCHAR(100),
    IN  p_client_name       VARCHAR(255),
    IN  p_status            VARCHAR(50),
    IN  p_total_amount      DECIMAL(15,2),
    IN  p_currency          VARCHAR(10),
    IN  p_no_of_items       INT,
    IN  p_order_date        DATE,
    IN  p_delivery_date     DATE,
    IN  p_linked_project_id BIGINT,
    IN  p_order_type_id     BIGINT,
    IN  p_notes             TEXT,
    IN  p_created_by        INT,
    OUT p_order_id          BIGINT
)
proc: BEGIN
    IF p_tenant_id IS NULL THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Tenant id is required';
    END IF;
    IF p_order_no IS NULL OR p_order_no = '' THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Order number is required';
    END IF;
    IF p_client_name IS NULL OR p_client_name = '' THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Client name is required';
    END IF;

    INSERT INTO fact_orders (
        tenant_id, order_no, client_name, status, total_amount, currency,
        no_of_items, order_date, delivery_date, linked_project_id, order_type_id, notes,
        created_at, created_by, updated_at, updated_by, is_deleted
    ) VALUES (
        p_tenant_id, p_order_no, p_client_name,
        COALESCE(p_status, 'Pending'),
        COALESCE(p_total_amount, 0),
        COALESCE(p_currency, 'USD'),
        COALESCE(p_no_of_items, 0),
        p_order_date, p_delivery_date, p_linked_project_id, p_order_type_id, p_notes,
        NOW(), p_created_by, NOW(), p_created_by, 0
    );

    SET p_order_id = LAST_INSERT_ID();
END$$

CREATE PROCEDURE sp_update_order(
    IN  p_tenant_id         INT,
    IN  p_order_id          BIGINT,
    IN  p_order_no          VARCHAR(100),
    IN  p_client_name       VARCHAR(255),
    IN  p_status            VARCHAR(50),
    IN  p_total_amount      DECIMAL(15,2),
    IN  p_currency          VARCHAR(10),
    IN  p_no_of_items       INT,
    IN  p_order_date        DATE,
    IN  p_delivery_date     DATE,
    IN  p_linked_project_id BIGINT,
    IN  p_order_type_id     BIGINT,
    IN  p_notes             TEXT,
    IN  p_updated_by        INT
)
proc: BEGIN
    DECLARE v_exists INT;
    SELECT COUNT(*) INTO v_exists FROM fact_orders
        WHERE id = p_order_id AND tenant_id = p_tenant_id AND is_deleted = 0;
    IF v_exists = 0 THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Order not found';
    END IF;

    UPDATE fact_orders SET
        order_no          = COALESCE(p_order_no, order_no),
        client_name       = COALESCE(p_client_name, client_name),
        status            = COALESCE(p_status, status),
        total_amount      = COALESCE(p_total_amount, total_amount),
        currency          = COALESCE(p_currency, currency),
        no_of_items       = COALESCE(p_no_of_items, no_of_items),
        order_date        = COALESCE(p_order_date, order_date),
        delivery_date     = COALESCE(p_delivery_date, delivery_date),
        linked_project_id = COALESCE(p_linked_project_id, linked_project_id),
        order_type_id     = COALESCE(p_order_type_id, order_type_id),
        notes             = COALESCE(p_notes, notes),
        updated_at        = NOW(),
        updated_by        = p_updated_by
    WHERE id = p_order_id AND tenant_id = p_tenant_id AND is_deleted = 0;
END$$

DELIMITER ;

-- ----- accounting_ledger_module.sql -----
USE `stacie_Aggie_v1.0`;

-- =============================================================================================
-- accounting_ledger_module.sql — Chart of Accounts, true double-entry journal postings, tax
-- rate catalog, accounting period locking, and third-party accounting integration scaffolding
-- (CA-level audit follow-up: the existing Finance module has real pay/void/transfer ledger
-- entries but no Chart of Accounts, no accrual support, no tax engine, no period locking, and no
-- QBO/Xero/Sage data model or integration).
--
-- dim_accounts is a lookup table (not a hardcoded Java enum), mirroring dim_business_types /
-- dim_order_types exactly: tenant_id NULL = system-seeded standard Chart of Accounts, visible to
-- every tenant; a tenant-owned row has tenant_id set for a custom account.
--
-- fact_journal_entries is a new header table grouping the FactLedgerEntry rows of one balanced
-- journal entry (see LedgerService.postJournalEntry, the double-entry validation point).
-- fact_ledger_entries gains account_id/journal_entry_id/debit_amount/credit_amount columns;
-- wallet_id is relaxed from NOT NULL to NULL because a pure GL leg (e.g. the Revenue/Expense side
-- of a journal entry) has no wallet role at all — existing wallet-bearing rows are unaffected.
--
-- dim_tax_rates is tenant-scoped (NOT system-seeded — every tenant defines its own jurisdiction
-- rates). fact_invoices and fact_order_line_items each gain an optional tax_rate_id.
--
-- dim_accounting_periods backs real period locking, enforced in LedgerService.postJournalEntry
-- (not just documented — every new journal entry is checked against locked periods before insert).
--
-- dim_accounting_integrations is scaffolding ONLY for QuickBooks Online / Xero / Sage: status is
-- always NOT_CONNECTED and the oauth_* columns stay NULL. No real API credentials exist anywhere
-- in this repo (checked abos-agents/.env.example and abos-api/deploy/backend-core.env.example —
-- neither defines QBO/Xero/Sage keys), so AccountingIntegrationService.connectIntegration always
-- throws rather than faking a successful connection.
--
-- Safe to re-run: CREATE TABLE IF NOT EXISTS, information_schema-guarded ALTERs, WHERE NOT EXISTS
-- seed guards — exact pattern as business_types_module.sql / order_types_module.sql.
-- =============================================================================================

SET @db := DATABASE();

-- ── Chart of Accounts ───────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS dim_accounts (
    id                BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id         INT NULL,
    account_code      VARCHAR(20) NOT NULL,
    account_name      VARCHAR(255) NOT NULL,
    account_type      VARCHAR(20) NOT NULL,
    parent_account_id BIGINT NULL,
    is_system         TINYINT NOT NULL DEFAULT 0,
    status            VARCHAR(20) NOT NULL DEFAULT 'Active',
    created_at        TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by        INT NULL,
    updated_at        TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by        INT NULL,
    is_deleted        TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_dim_accounts_code (tenant_id, account_code),
    INDEX idx_dim_accounts_tenant (tenant_id),
    INDEX idx_dim_accounts_parent (parent_account_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── Journal entry header (groups balanced FactLedgerEntry legs) ─────────────────────────────
CREATE TABLE IF NOT EXISTS fact_journal_entries (
    id               BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id        INT NOT NULL,
    business_id      INT NULL,
    posting_date     DATE NOT NULL,
    memo             VARCHAR(500) NULL,
    reference_entity VARCHAR(100) NULL,
    reference_id     BIGINT NULL,
    created_at       TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by       INT NULL,
    updated_at       TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by       INT NULL,
    is_deleted       TINYINT NOT NULL DEFAULT 0,
    INDEX idx_fact_journal_entries_tenant (tenant_id),
    INDEX idx_fact_journal_entries_business (business_id),
    INDEX idx_fact_journal_entries_posting_date (posting_date),
    INDEX idx_fact_journal_entries_reference (reference_entity, reference_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── Tax rate catalog (tenant-scoped, not system-seeded) ─────────────────────────────────────
CREATE TABLE IF NOT EXISTS dim_tax_rates (
    id             BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id      INT NOT NULL,
    name           VARCHAR(255) NOT NULL,
    rate_percentage DECIMAL(7,4) NOT NULL,
    jurisdiction   VARCHAR(100) NULL,
    is_default     TINYINT NOT NULL DEFAULT 0,
    created_at     TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by     INT NULL,
    updated_at     TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by     INT NULL,
    is_deleted     TINYINT NOT NULL DEFAULT 0,
    INDEX idx_dim_tax_rates_tenant (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── Accounting period locking ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS dim_accounting_periods (
    id           BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id    INT NOT NULL,
    business_id  INT NULL,
    period_start DATE NOT NULL,
    period_end   DATE NOT NULL,
    is_locked    TINYINT NOT NULL DEFAULT 0,
    locked_at    TIMESTAMP(6) NULL,
    locked_by    INT NULL,
    created_at   TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by   INT NULL,
    updated_at   TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by   INT NULL,
    is_deleted   TINYINT NOT NULL DEFAULT 0,
    INDEX idx_dim_accounting_periods_tenant (tenant_id),
    INDEX idx_dim_accounting_periods_range (tenant_id, period_start, period_end)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── Third-party accounting integration scaffolding (NOT functional) ─────────────────────────
CREATE TABLE IF NOT EXISTS dim_accounting_integrations (
    id                  BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id           INT NOT NULL,
    provider            VARCHAR(20) NOT NULL,
    status              VARCHAR(20) NOT NULL DEFAULT 'NOT_CONNECTED',
    oauth_access_token  TEXT NULL,
    oauth_refresh_token TEXT NULL,
    oauth_expires_at    TIMESTAMP(6) NULL,
    created_at          TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
    created_by          INT NULL,
    updated_at          TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    updated_by          INT NULL,
    is_deleted          TINYINT NOT NULL DEFAULT 0,
    UNIQUE KEY uq_dim_accounting_integrations_provider (tenant_id, provider),
    INDEX idx_dim_accounting_integrations_tenant (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ── fact_ledger_entries: add account_id / journal_entry_id / debit_amount / credit_amount ───
SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_ledger_entries' AND COLUMN_NAME='account_id'),
  'ALTER TABLE fact_ledger_entries ADD COLUMN account_id BIGINT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_ledger_entries' AND COLUMN_NAME='journal_entry_id'),
  'ALTER TABLE fact_ledger_entries ADD COLUMN journal_entry_id BIGINT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_ledger_entries' AND COLUMN_NAME='debit_amount'),
  'ALTER TABLE fact_ledger_entries ADD COLUMN debit_amount DECIMAL(15,2) NULL DEFAULT 0.00');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_ledger_entries' AND COLUMN_NAME='credit_amount'),
  'ALTER TABLE fact_ledger_entries ADD COLUMN credit_amount DECIMAL(15,2) NULL DEFAULT 0.00');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- MODIFY COLUMN is naturally idempotent (re-running is a no-op once already nullable) — relax
-- wallet_id from NOT NULL so a pure GL leg (Revenue/Expense side of a journal entry) can omit it.
-- Every pre-existing wallet-bearing row is untouched; every existing caller keeps setting it.
ALTER TABLE fact_ledger_entries MODIFY COLUMN wallet_id INT NULL;

-- ── fact_invoices: add tax_rate_id ───────────────────────────────────────────────────────────
SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_invoices' AND COLUMN_NAME='tax_rate_id'),
  'ALTER TABLE fact_invoices ADD COLUMN tax_rate_id BIGINT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- ── fact_order_line_items: add tax_rate_id / tax_amount ──────────────────────────────────────
SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_order_line_items' AND COLUMN_NAME='tax_rate_id'),
  'ALTER TABLE fact_order_line_items ADD COLUMN tax_rate_id BIGINT NULL');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

SET @sql := IFNULL((SELECT 'SELECT 1' FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=@db AND TABLE_NAME='fact_order_line_items' AND COLUMN_NAME='tax_amount'),
  'ALTER TABLE fact_order_line_items ADD COLUMN tax_amount DECIMAL(15,2) NULL DEFAULT 0.00');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;

-- ── Seed the standard Chart of Accounts (tenant_id NULL = visible to every tenant) ──────────
INSERT INTO dim_accounts (tenant_id, account_code, account_name, account_type, parent_account_id, is_system, status, is_deleted)
SELECT NULL, v.account_code, v.account_name, v.account_type, NULL, 1, 'Active', 0
FROM (
    SELECT '1000' account_code, 'Cash' account_name, 'ASSET' account_type
    UNION ALL SELECT '1100', 'Accounts Receivable', 'ASSET'
    UNION ALL SELECT '2000', 'Accounts Payable', 'LIABILITY'
    UNION ALL SELECT '3000', "Owner's Equity", 'EQUITY'
    UNION ALL SELECT '4000', 'Revenue / Sales Income', 'REVENUE'
    UNION ALL SELECT '5000', 'Cost of Goods Sold', 'EXPENSE'
    UNION ALL SELECT '6000', 'Operating Expenses', 'EXPENSE'
) v
WHERE NOT EXISTS (
    SELECT 1 FROM dim_accounts a WHERE a.tenant_id IS NULL AND a.account_code = v.account_code
);

-- ----- web_store_stock_reservation.sql -----
USE `stacie_Aggie_v1.0`;

-- =============================================================================================
-- web_store_stock_reservation.sql — cart-reservation support for the storefront checkout flow
-- (companion to web_store_module.sql / inventory_module.sql, applied after both).
--
-- fact_inventory_stock.quantity_reserved already exists (inventory_module.sql) but nothing ever
-- set it — there was no cart-reservation flow at all, so two shoppers could both "successfully"
-- add the last unit to their carts and only the first to actually pay would get it. This adds a
-- cached mirror of the reserved total on dim_web_product_variants — reserved_qty — the exact same
-- "maintained rollup, cheap to read from the storefront" role stock_qty already plays for
-- quantity_on_hand (see InventoryService.syncVariantStockCache). Available-to-sell for storefront
-- display is stock_qty - reserved_qty, computed at the read site (PublicWebService), not stored.
--
-- Safe to re-run: information_schema-guarded ALTER.
-- =============================================================================================

SET @col_exists = (
    SELECT COUNT(*) FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'dim_web_product_variants' AND COLUMN_NAME = 'reserved_qty'
);
SET @sql = IF(@col_exists = 0,
    'ALTER TABLE dim_web_product_variants ADD COLUMN reserved_qty INT NOT NULL DEFAULT 0 AFTER stock_qty',
    'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

-- ----- hr_events_notes_column.sql -----
USE `stacie_Aggie_v1.0`;

-- Adds fact_hr_events.notes — the new HR Events backend module (HrEventService/HrEventController)
-- stores the frontend's "purpose" and "notes" fields separately, but the table only had a single
-- `description` column. Guarded so it's safe to re-run.

SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fact_hr_events' AND COLUMN_NAME = 'notes');
SET @sql = IF(@col_exists = 0, 'ALTER TABLE `fact_hr_events` ADD COLUMN `notes` tinytext AFTER `description`', 'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

