feat: add multi-platform CMS architecture (Phase 1)

Implement the foundation for multi-platform support allowing independent
business offerings (OMS, Loyalty, etc.) with their own CMS pages.

Database Models:
- Add Platform model for business offerings (domain, branding, config)
- Add VendorPlatform junction table for many-to-many relationship
- Update SubscriptionTier with platform_id and CMS limits
- Update ContentPage with platform_id, is_platform_page for three-tier hierarchy
- Add CMS feature codes (cms_basic, cms_custom_pages, cms_templates, etc.)

Three-Tier Content Resolution:
1. Vendor override (platform_id + vendor_id + slug)
2. Vendor default (platform_id + vendor_id=NULL + is_platform_page=False)
3. Platform marketing pages (is_platform_page=True)

New Components:
- PlatformContextMiddleware for detecting platform from domain/path
- ContentPageService updated with full three-tier resolution
- Platform folder structure (app/platforms/oms/, app/platforms/loyalty/)
- Alembic migration with backfill for existing data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-18 19:49:44 +01:00
parent 4c9b3c4e4b
commit 408019dbb3
24 changed files with 2049 additions and 287 deletions

View File

@@ -0,0 +1,374 @@
"""add multi-platform support
Revision ID: z4e5f6a7b8c9
Revises: 1b398cf45e85
Create Date: 2026-01-18 12:00:00.000000
This migration adds multi-platform support:
1. Creates platforms table for business offerings (OMS, Loyalty, etc.)
2. Creates vendor_platforms junction table for many-to-many relationship
3. Adds platform_id and CMS limits to subscription_tiers
4. Adds platform_id and is_platform_page to content_pages
5. Inserts default "oms" platform
6. Backfills existing data to OMS platform
7. Creates vendor_platforms entries for existing vendors
"""
import json
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "z4e5f6a7b8c9"
down_revision: Union[str, None] = "1b398cf45e85"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Platform marketing page slugs (is_platform_page=True)
PLATFORM_PAGE_SLUGS = [
"platform_homepage",
"home",
"pricing",
"about",
"contact",
"faq",
"terms",
"privacy",
"features",
"integrations",
]
# CMS limits per tier
CMS_TIER_LIMITS = {
"essential": {"cms_pages_limit": 3, "cms_custom_pages_limit": 0},
"professional": {"cms_pages_limit": 10, "cms_custom_pages_limit": 5},
"business": {"cms_pages_limit": 30, "cms_custom_pages_limit": 20},
"enterprise": {"cms_pages_limit": None, "cms_custom_pages_limit": None}, # Unlimited
}
# CMS features per tier
CMS_TIER_FEATURES = {
"essential": ["cms_basic"],
"professional": ["cms_basic", "cms_custom_pages", "cms_seo"],
"business": ["cms_basic", "cms_custom_pages", "cms_seo", "cms_templates"],
"enterprise": ["cms_basic", "cms_custom_pages", "cms_unlimited_pages", "cms_templates", "cms_seo", "cms_scheduling"],
}
def upgrade() -> None:
conn = op.get_bind()
# =========================================================================
# 1. Create platforms table
# =========================================================================
op.create_table(
"platforms",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("code", sa.String(50), nullable=False),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("domain", sa.String(255), nullable=True),
sa.Column("path_prefix", sa.String(50), nullable=True),
sa.Column("logo", sa.String(500), nullable=True),
sa.Column("logo_dark", sa.String(500), nullable=True),
sa.Column("favicon", sa.String(500), nullable=True),
sa.Column("theme_config", sa.JSON(), nullable=True),
sa.Column("default_language", sa.String(5), nullable=False, server_default="fr"),
sa.Column("supported_languages", sa.JSON(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("is_public", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("settings", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_platforms_code", "platforms", ["code"], unique=True)
op.create_index("ix_platforms_domain", "platforms", ["domain"], unique=True)
op.create_index("ix_platforms_path_prefix", "platforms", ["path_prefix"], unique=True)
op.create_index("idx_platform_active", "platforms", ["is_active"])
op.create_index("idx_platform_public", "platforms", ["is_public", "is_active"])
# =========================================================================
# 2. Create vendor_platforms junction table
# =========================================================================
op.create_table(
"vendor_platforms",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("vendor_id", sa.Integer(), nullable=False),
sa.Column("platform_id", sa.Integer(), nullable=False),
sa.Column("tier_id", sa.Integer(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("custom_subdomain", sa.String(100), nullable=True),
sa.Column("settings", sa.JSON(), nullable=True),
sa.Column("joined_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["vendor_id"], ["vendors.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["platform_id"], ["platforms.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tier_id"], ["subscription_tiers.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_vendor_platforms_vendor_id", "vendor_platforms", ["vendor_id"])
op.create_index("ix_vendor_platforms_platform_id", "vendor_platforms", ["platform_id"])
op.create_index("ix_vendor_platforms_tier_id", "vendor_platforms", ["tier_id"])
op.create_index("idx_vendor_platform_active", "vendor_platforms", ["vendor_id", "platform_id", "is_active"])
op.create_index("idx_vendor_platform_primary", "vendor_platforms", ["vendor_id", "is_primary"])
op.create_unique_constraint("uq_vendor_platform", "vendor_platforms", ["vendor_id", "platform_id"])
# =========================================================================
# 3. Add platform_id and CMS columns to subscription_tiers
# =========================================================================
# Add platform_id column (nullable for global tiers)
op.add_column(
"subscription_tiers",
sa.Column("platform_id", sa.Integer(), nullable=True),
)
op.create_index("ix_subscription_tiers_platform_id", "subscription_tiers", ["platform_id"])
op.create_foreign_key(
"fk_subscription_tiers_platform_id",
"subscription_tiers",
"platforms",
["platform_id"],
["id"],
ondelete="CASCADE",
)
# Add CMS limit columns
op.add_column(
"subscription_tiers",
sa.Column("cms_pages_limit", sa.Integer(), nullable=True),
)
op.add_column(
"subscription_tiers",
sa.Column("cms_custom_pages_limit", sa.Integer(), nullable=True),
)
op.create_index("idx_tier_platform_active", "subscription_tiers", ["platform_id", "is_active"])
# =========================================================================
# 4. Add platform_id and is_platform_page to content_pages
# =========================================================================
# Add platform_id column (will be set to NOT NULL after backfill)
op.add_column(
"content_pages",
sa.Column("platform_id", sa.Integer(), nullable=True),
)
op.create_index("ix_content_pages_platform_id", "content_pages", ["platform_id"])
# Add is_platform_page column
op.add_column(
"content_pages",
sa.Column("is_platform_page", sa.Boolean(), nullable=False, server_default="false"),
)
op.create_index("idx_platform_page_type", "content_pages", ["platform_id", "is_platform_page"])
# =========================================================================
# 5. Insert default OMS platform
# =========================================================================
conn.execute(
sa.text("""
INSERT INTO platforms (code, name, description, domain, path_prefix, default_language,
supported_languages, is_active, is_public, theme_config, settings,
created_at, updated_at)
VALUES ('oms', 'Wizamart OMS', 'Order Management System for Luxembourg merchants',
'oms.lu', 'oms', 'fr', '["fr", "de", "en"]', true, true, '{}', '{}',
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""")
)
# Get the OMS platform ID
result = conn.execute(sa.text("SELECT id FROM platforms WHERE code = 'oms'"))
oms_platform_id = result.fetchone()[0]
# =========================================================================
# 6. Backfill content_pages with platform_id
# =========================================================================
conn.execute(
sa.text(f"UPDATE content_pages SET platform_id = {oms_platform_id} WHERE platform_id IS NULL")
)
# Set is_platform_page=True for platform marketing page slugs
# Only for pages that have vendor_id=NULL (platform-level pages)
slugs_list = ", ".join([f"'{slug}'" for slug in PLATFORM_PAGE_SLUGS])
conn.execute(
sa.text(f"""
UPDATE content_pages
SET is_platform_page = true
WHERE vendor_id IS NULL AND slug IN ({slugs_list})
""")
)
# Make platform_id NOT NULL after backfill
op.alter_column("content_pages", "platform_id", nullable=False)
# Add foreign key constraint
op.create_foreign_key(
"fk_content_pages_platform_id",
"content_pages",
"platforms",
["platform_id"],
["id"],
ondelete="CASCADE",
)
# =========================================================================
# 7. Update content_pages constraints
# =========================================================================
# Drop old unique constraint
op.drop_constraint("uq_vendor_slug", "content_pages", type_="unique")
# Create new unique constraint including platform_id
op.create_unique_constraint(
"uq_platform_vendor_slug",
"content_pages",
["platform_id", "vendor_id", "slug"],
)
# Update indexes
op.drop_index("idx_vendor_published", table_name="content_pages")
op.drop_index("idx_slug_published", table_name="content_pages")
op.create_index("idx_platform_vendor_published", "content_pages", ["platform_id", "vendor_id", "is_published"])
op.create_index("idx_platform_slug_published", "content_pages", ["platform_id", "slug", "is_published"])
# =========================================================================
# 8. Update subscription_tiers with CMS limits
# =========================================================================
for tier_code, limits in CMS_TIER_LIMITS.items():
cms_pages = limits["cms_pages_limit"] if limits["cms_pages_limit"] is not None else "NULL"
cms_custom = limits["cms_custom_pages_limit"] if limits["cms_custom_pages_limit"] is not None else "NULL"
conn.execute(
sa.text(f"""
UPDATE subscription_tiers
SET cms_pages_limit = {cms_pages},
cms_custom_pages_limit = {cms_custom}
WHERE code = '{tier_code}'
""")
)
# Add CMS features to each tier
for tier_code, cms_features in CMS_TIER_FEATURES.items():
# Get current features
result = conn.execute(
sa.text(f"SELECT features FROM subscription_tiers WHERE code = '{tier_code}'")
)
row = result.fetchone()
if row and row[0]:
current_features = json.loads(row[0]) if isinstance(row[0], str) else row[0]
else:
current_features = []
# Add CMS features that aren't already present
for feature in cms_features:
if feature not in current_features:
current_features.append(feature)
# Update features
features_json = json.dumps(current_features)
conn.execute(
sa.text(f"UPDATE subscription_tiers SET features = '{features_json}' WHERE code = '{tier_code}'")
)
# =========================================================================
# 9. Create vendor_platforms entries for existing vendors
# =========================================================================
# Get all vendors with their subscription tier_id
conn.execute(
sa.text(f"""
INSERT INTO vendor_platforms (vendor_id, platform_id, tier_id, is_active, is_primary,
joined_at, created_at, updated_at)
SELECT v.id, {oms_platform_id}, vs.tier_id, v.is_active, true,
v.created_at, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM vendors v
LEFT JOIN vendor_subscriptions vs ON vs.vendor_id = v.id
""")
)
# =========================================================================
# 10. Add CMS feature records to features table
# =========================================================================
# Get minimum tier IDs for CMS features
tier_ids = {}
result = conn.execute(sa.text("SELECT id, code FROM subscription_tiers"))
for row in result:
tier_ids[row[1]] = row[0]
cms_features = [
("cms", "cms_basic", "Basic CMS", "Override default pages with custom content", "settings", "document-text", None, tier_ids.get("essential"), 1),
("cms", "cms_custom_pages", "Custom Pages", "Create custom pages beyond defaults", "settings", "document-add", None, tier_ids.get("professional"), 2),
("cms", "cms_unlimited_pages", "Unlimited Pages", "No page limit", "settings", "documents", None, tier_ids.get("enterprise"), 3),
("cms", "cms_templates", "Page Templates", "Access to page templates", "settings", "template", None, tier_ids.get("business"), 4),
("cms", "cms_seo", "Advanced SEO", "SEO metadata and optimization", "settings", "search", None, tier_ids.get("professional"), 5),
("cms", "cms_scheduling", "Page Scheduling", "Schedule page publish/unpublish", "settings", "clock", None, tier_ids.get("enterprise"), 6),
]
for category, code, name, description, ui_location, ui_icon, ui_route, minimum_tier_id, display_order in cms_features:
min_tier_val = minimum_tier_id if minimum_tier_id else "NULL"
conn.execute(
sa.text(f"""
INSERT INTO features (code, name, description, category, ui_location, ui_icon, ui_route,
minimum_tier_id, is_active, is_visible, display_order, created_at, updated_at)
VALUES ('{code}', '{name}', '{description}', '{category}', '{ui_location}', '{ui_icon}', NULL,
{min_tier_val}, true, true, {display_order}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (code) DO NOTHING
""")
)
def downgrade() -> None:
conn = op.get_bind()
# Remove CMS features from features table
conn.execute(
sa.text("""
DELETE FROM features
WHERE code IN ('cms_basic', 'cms_custom_pages', 'cms_unlimited_pages',
'cms_templates', 'cms_seo', 'cms_scheduling')
""")
)
# Remove CMS features from subscription_tiers
result = conn.execute(sa.text("SELECT id, code, features FROM subscription_tiers"))
for row in result:
tier_id, tier_code, features_data = row
if features_data:
current_features = json.loads(features_data) if isinstance(features_data, str) else features_data
# Remove CMS features
updated_features = [f for f in current_features if not f.startswith("cms_")]
features_json = json.dumps(updated_features)
conn.execute(
sa.text(f"UPDATE subscription_tiers SET features = '{features_json}' WHERE id = {tier_id}")
)
# Drop vendor_platforms table
op.drop_table("vendor_platforms")
# Restore old content_pages constraints
op.drop_constraint("uq_platform_vendor_slug", "content_pages", type_="unique")
op.drop_index("idx_platform_vendor_published", table_name="content_pages")
op.drop_index("idx_platform_slug_published", table_name="content_pages")
op.drop_index("idx_platform_page_type", table_name="content_pages")
op.drop_constraint("fk_content_pages_platform_id", "content_pages", type_="foreignkey")
op.drop_index("ix_content_pages_platform_id", table_name="content_pages")
op.drop_column("content_pages", "is_platform_page")
op.drop_column("content_pages", "platform_id")
op.create_unique_constraint("uq_vendor_slug", "content_pages", ["vendor_id", "slug"])
op.create_index("idx_vendor_published", "content_pages", ["vendor_id", "is_published"])
op.create_index("idx_slug_published", "content_pages", ["slug", "is_published"])
# Remove subscription_tiers platform columns
op.drop_index("idx_tier_platform_active", table_name="subscription_tiers")
op.drop_constraint("fk_subscription_tiers_platform_id", "subscription_tiers", type_="foreignkey")
op.drop_index("ix_subscription_tiers_platform_id", table_name="subscription_tiers")
op.drop_column("subscription_tiers", "cms_custom_pages_limit")
op.drop_column("subscription_tiers", "cms_pages_limit")
op.drop_column("subscription_tiers", "platform_id")
# Drop platforms table
op.drop_index("idx_platform_public", table_name="platforms")
op.drop_index("idx_platform_active", table_name="platforms")
op.drop_index("ix_platforms_path_prefix", table_name="platforms")
op.drop_index("ix_platforms_domain", table_name="platforms")
op.drop_index("ix_platforms_code", table_name="platforms")
op.drop_table("platforms")