refactor: rename public routes and templates to platform

Complete the public -> platform naming migration across the codebase.
This aligns with the naming convention where "platform" refers to
the marketing/public-facing pages of the platform itself.

Changes:
- Update all imports from public to platform modules
- Update template references from public/ to platform/
- Update route registrations to use platform prefix
- Update documentation to reflect new naming
- Update test files for platform API endpoints

Files affected:
- app/api/main.py - router imports
- app/modules/*/routes/*/platform.py - route definitions
- app/modules/*/templates/*/platform/ - template files
- app/modules/routes.py - route discovery
- docs/* - documentation updates
- tests/integration/api/v1/platform/ - test files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-02 18:49:39 +01:00
parent 967f08e4ba
commit fb8cb14506
44 changed files with 980 additions and 327 deletions

View File

@@ -10,7 +10,7 @@ This module provides:
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import admin, public, storefront, vendor, webhooks from app.api.v1 import admin, platform, storefront, vendor, webhooks
api_router = APIRouter() api_router = APIRouter()
@@ -37,12 +37,12 @@ api_router.include_router(vendor.router, prefix="/v1/vendor", tags=["vendor"])
api_router.include_router(storefront.router, prefix="/v1/storefront", tags=["storefront"]) api_router.include_router(storefront.router, prefix="/v1/storefront", tags=["storefront"])
# ============================================================================ # ============================================================================
# PUBLIC ROUTES (Unauthenticated endpoints) # PLATFORM ROUTES (Unauthenticated endpoints)
# Prefix: /api/v1/public # Prefix: /api/v1/platform
# Includes: /signup, /pricing, /letzshop-vendors, /language # Includes: /signup, /pricing, /letzshop-vendors, /language
# ============================================================================ # ============================================================================
api_router.include_router(public.router, prefix="/v1/public", tags=["public"]) api_router.include_router(platform.router, prefix="/v1/platform", tags=["platform"])
# ============================================================================ # ============================================================================
# WEBHOOK ROUTES (External service callbacks via auto-discovery) # WEBHOOK ROUTES (External service callbacks via auto-discovery)

View File

@@ -1,11 +1,11 @@
# app/api/v1/public/__init__.py # app/api/v1/platform/__init__.py
""" """
Public API endpoints (no authentication required). Platform API endpoints (no authentication required).
Includes: Includes:
- signup: /signup/* (multi-step signup flow - cross-cutting) - signup: /signup/* (multi-step signup flow - cross-cutting)
Auto-discovers and aggregates public routes from self-contained modules: Auto-discovers and aggregates platform routes from self-contained modules:
- billing: /pricing/* (subscription tiers and add-ons) - billing: /pricing/* (subscription tiers and add-ons)
- marketplace: /letzshop-vendors/* (vendor lookup for signup) - marketplace: /letzshop-vendors/* (vendor lookup for signup)
- core: /language/* (language preferences) - core: /language/* (language preferences)
@@ -15,16 +15,16 @@ These endpoints serve the marketing homepage, pricing pages, and signup flows.
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1.public import signup from app.api.v1.platform import signup
from app.modules.routes import get_public_api_routes from app.modules.routes import get_platform_api_routes
router = APIRouter() router = APIRouter()
# Cross-cutting signup flow (spans auth, vendors, billing, payments) # Cross-cutting signup flow (spans auth, vendors, billing, payments)
router.include_router(signup.router, tags=["public-signup"]) router.include_router(signup.router, tags=["platform-signup"])
# Auto-discover public routes from modules # Auto-discover platform routes from modules
for route_info in get_public_api_routes(): for route_info in get_platform_api_routes():
if route_info.custom_prefix: if route_info.custom_prefix:
router.include_router( router.include_router(
route_info.router, route_info.router,

View File

@@ -1,4 +1,4 @@
# app/api/v1/public/signup.py # app/api/v1/platform/signup.py
""" """
Platform signup API endpoints. Platform signup API endpoints.

View File

@@ -1,11 +1,11 @@
# app/modules/billing/routes/api/public.py # app/modules/billing/routes/api/platform.py
""" """
Public pricing API endpoints. Platform pricing API endpoints.
Provides subscription tier and add-on product information Provides subscription tier and add-on product information
for the marketing homepage and signup flow. for the marketing homepage and signup flow.
All endpoints are public (no authentication required). All endpoints are unauthenticated (no authentication required).
""" """
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends

View File

@@ -1,8 +1,8 @@
# app/modules/billing/routes/pages/public.py # app/modules/billing/routes/pages/platform.py
""" """
Billing Public Page Routes (HTML rendering). Billing Platform Page Routes (HTML rendering).
Public (unauthenticated) pages for pricing and signup: Platform (unauthenticated) pages for pricing and signup:
- Pricing page - Pricing page
- Signup wizard - Signup wizard
- Signup success - Signup success
@@ -14,7 +14,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.modules.billing.models import TIER_LIMITS, TierCode from app.modules.billing.models import TIER_LIMITS, TierCode
from app.modules.core.utils.page_context import get_public_context from app.modules.core.utils.page_context import get_platform_context
from app.templates_config import templates from app.templates_config import templates
router = APIRouter() router = APIRouter()
@@ -57,12 +57,12 @@ async def pricing_page(
""" """
Standalone pricing page with detailed tier comparison. Standalone pricing page with detailed tier comparison.
""" """
context = get_public_context(request, db) context = get_platform_context(request, db)
context["tiers"] = _get_tiers_data() context["tiers"] = _get_tiers_data()
context["page_title"] = "Pricing" context["page_title"] = "Pricing"
return templates.TemplateResponse( return templates.TemplateResponse(
"billing/public/pricing.html", "billing/platform/pricing.html",
context, context,
) )
@@ -86,14 +86,14 @@ async def signup_page(
- tier: Pre-selected tier code - tier: Pre-selected tier code
- annual: Pre-select annual billing - annual: Pre-select annual billing
""" """
context = get_public_context(request, db) context = get_platform_context(request, db)
context["page_title"] = "Start Your Free Trial" context["page_title"] = "Start Your Free Trial"
context["selected_tier"] = tier context["selected_tier"] = tier
context["is_annual"] = annual context["is_annual"] = annual
context["tiers"] = _get_tiers_data() context["tiers"] = _get_tiers_data()
return templates.TemplateResponse( return templates.TemplateResponse(
"billing/public/signup.html", "billing/platform/signup.html",
context, context,
) )
@@ -111,11 +111,11 @@ async def signup_success_page(
Shown after successful account creation. Shown after successful account creation.
""" """
context = get_public_context(request, db) context = get_platform_context(request, db)
context["page_title"] = "Welcome to Wizamart!" context["page_title"] = "Welcome to Wizamart!"
context["vendor_code"] = vendor_code context["vendor_code"] = vendor_code
return templates.TemplateResponse( return templates.TemplateResponse(
"billing/public/signup-success.html", "billing/platform/signup-success.html",
context, context,
) )

View File

@@ -1,6 +1,6 @@
{# app/templates/platform/pricing.html #} {# app/templates/platform/pricing.html #}
{# Standalone Pricing Page #} {# Standalone Pricing Page #}
{% extends "public/base.html" %} {% extends "platform/base.html" %}
{% block title %}{{ _("cms.platform.pricing.title") }} - Wizamart{% endblock %} {% block title %}{{ _("cms.platform.pricing.title") }} - Wizamart{% endblock %}

View File

@@ -1,6 +1,6 @@
{# app/templates/platform/signup-success.html #} {# app/templates/platform/signup-success.html #}
{# Signup Success Page #} {# Signup Success Page #}
{% extends "public/base.html" %} {% extends "platform/base.html" %}
{% block title %}{{ _("cms.platform.success.title") }}{% endblock %} {% block title %}{{ _("cms.platform.success.title") }}{% endblock %}

View File

@@ -1,6 +1,6 @@
{# app/templates/platform/signup.html #} {# app/templates/platform/signup.html #}
{# Multi-step Signup Wizard #} {# Multi-step Signup Wizard #}
{% extends "public/base.html" %} {% extends "platform/base.html" %}
{% block title %}Start Your Free Trial - Wizamart{% endblock %} {% block title %}Start Your Free Trial - Wizamart{% endblock %}
@@ -321,7 +321,7 @@ function signupWizard() {
async startSignup() { async startSignup() {
this.loading = true; this.loading = true;
try { try {
const response = await fetch('/api/v1/public/signup/start', { const response = await fetch('/api/v1/platform/signup/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -352,7 +352,7 @@ function signupWizard() {
try { try {
// First lookup the vendor // First lookup the vendor
const lookupResponse = await fetch('/api/v1/public/letzshop-vendors/lookup', { const lookupResponse = await fetch('/api/v1/platform/letzshop-vendors/lookup', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: this.letzshopUrl }) body: JSON.stringify({ url: this.letzshopUrl })
@@ -364,7 +364,7 @@ function signupWizard() {
this.letzshopVendor = lookupData.vendor; this.letzshopVendor = lookupData.vendor;
// Claim the vendor // Claim the vendor
const claimResponse = await fetch('/api/v1/public/signup/claim-vendor', { const claimResponse = await fetch('/api/v1/platform/signup/claim-vendor', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -411,7 +411,7 @@ function signupWizard() {
this.accountError = null; this.accountError = null;
try { try {
const response = await fetch('/api/v1/public/signup/create-account', { const response = await fetch('/api/v1/platform/signup/create-account', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -461,7 +461,7 @@ function signupWizard() {
// Get SetupIntent // Get SetupIntent
try { try {
const response = await fetch('/api/v1/public/signup/setup-payment', { const response = await fetch('/api/v1/platform/signup/setup-payment', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: this.sessionId }) body: JSON.stringify({ session_id: this.sessionId })
@@ -500,7 +500,7 @@ function signupWizard() {
} }
// Complete signup // Complete signup
const response = await fetch('/api/v1/public/signup/complete', { const response = await fetch('/api/v1/platform/signup/complete', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({

View File

@@ -1,8 +1,8 @@
# app/modules/cms/routes/pages/public.py # app/modules/cms/routes/pages/platform.py
""" """
CMS Public Page Routes (HTML rendering). CMS Platform Page Routes (HTML rendering).
Public (unauthenticated) pages for platform content: Platform (unauthenticated) pages for platform content:
- Homepage - Homepage
- Generic content pages (/{slug} catch-all) - Generic content pages (/{slug} catch-all)
""" """
@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.modules.billing.models import TIER_LIMITS, TierCode from app.modules.billing.models import TIER_LIMITS, TierCode
from app.modules.cms.services import content_page_service from app.modules.cms.services import content_page_service
from app.modules.core.utils.page_context import get_public_context from app.modules.core.utils.page_context import get_platform_context
from app.templates_config import templates from app.templates_config import templates
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -147,19 +147,19 @@ async def homepage(
if cms_homepage: if cms_homepage:
# Use CMS-based homepage with template selection # Use CMS-based homepage with template selection
context = get_public_context(request, db) context = get_platform_context(request, db)
context["page"] = cms_homepage context["page"] = cms_homepage
context["tiers"] = _get_tiers_data() context["tiers"] = _get_tiers_data()
template_name = cms_homepage.template or "default" template_name = cms_homepage.template or "default"
template_path = f"cms/public/homepage-{template_name}.html" template_path = f"cms/platform/homepage-{template_name}.html"
logger.info(f"[HOMEPAGE] Rendering CMS homepage with template: {template_path}") logger.info(f"[HOMEPAGE] Rendering CMS homepage with template: {template_path}")
return templates.TemplateResponse(template_path, context) return templates.TemplateResponse(template_path, context)
# Fallback: Default wizamart homepage (no CMS content) # Fallback: Default wizamart homepage (no CMS content)
logger.info("[HOMEPAGE] No CMS homepage found, using default wizamart template") logger.info("[HOMEPAGE] No CMS homepage found, using default wizamart template")
context = get_public_context(request, db) context = get_platform_context(request, db)
context["tiers"] = _get_tiers_data() context["tiers"] = _get_tiers_data()
# Add-ons (hardcoded for now, will come from DB) # Add-ons (hardcoded for now, will come from DB)
@@ -196,7 +196,7 @@ async def homepage(
] ]
return templates.TemplateResponse( return templates.TemplateResponse(
"cms/public/homepage-wizamart.html", "cms/platform/homepage-wizamart.html",
context, context,
) )
@@ -231,11 +231,11 @@ async def content_page(
if not page: if not page:
raise HTTPException(status_code=404, detail=f"Page not found: {slug}") raise HTTPException(status_code=404, detail=f"Page not found: {slug}")
context = get_public_context(request, db) context = get_platform_context(request, db)
context["page"] = page context["page"] = page
context["page_title"] = page.title context["page_title"] = page.title
return templates.TemplateResponse( return templates.TemplateResponse(
"cms/public/content-page.html", "cms/platform/content-page.html",
context, context,
) )

View File

@@ -1,6 +1,6 @@
{# app/templates/platform/content-page.html #} {# app/templates/platform/content-page.html #}
{# Generic template for platform content pages (About, FAQ, Terms, Contact, etc.) #} {# Generic template for platform content pages (About, FAQ, Terms, Contact, etc.) #}
{% extends "public/base.html" %} {% extends "platform/base.html" %}
{% block title %}{{ page.title }} - Marketplace{% endblock %} {% block title %}{{ page.title }} - Marketplace{% endblock %}

View File

@@ -1,6 +1,6 @@
{# app/templates/platform/homepage-default.html #} {# app/templates/platform/homepage-default.html #}
{# Default platform homepage template with section-based rendering #} {# Default platform homepage template with section-based rendering #}
{% extends "public/base.html" %} {% extends "platform/base.html" %}
{# Import section partials #} {# Import section partials #}
{% from 'platform/sections/_hero.html' import render_hero %} {% from 'platform/sections/_hero.html' import render_hero %}

View File

@@ -1,6 +1,6 @@
{# app/templates/platform/homepage-minimal.html #} {# app/templates/platform/homepage-minimal.html #}
{# Minimal/clean platform homepage template #} {# Minimal/clean platform homepage template #}
{% extends "public/base.html" %} {% extends "platform/base.html" %}
{% block title %} {% block title %}
{% if page %}{{ page.title }}{% else %}Home{% endif %} - Marketplace {% if page %}{{ page.title }}{% else %}Home{% endif %} - Marketplace

View File

@@ -1,6 +1,6 @@
{# app/templates/platform/homepage-modern.html #} {# app/templates/platform/homepage-modern.html #}
{# Wizamart OMS - Luxembourg-focused homepage inspired by Veeqo #} {# Wizamart OMS - Luxembourg-focused homepage inspired by Veeqo #}
{% extends "public/base.html" %} {% extends "platform/base.html" %}
{% block title %} {% block title %}
Wizamart - The Back-Office for Letzshop Sellers Wizamart - The Back-Office for Letzshop Sellers

View File

@@ -1,6 +1,6 @@
{# app/templates/platform/homepage-wizamart.html #} {# app/templates/platform/homepage-wizamart.html #}
{# Wizamart Marketing Homepage - Letzshop OMS Platform #} {# Wizamart Marketing Homepage - Letzshop OMS Platform #}
{% extends "public/base.html" %} {% extends "platform/base.html" %}
{% from 'shared/macros/inputs.html' import toggle_switch %} {% from 'shared/macros/inputs.html' import toggle_switch %}
{% block title %}Wizamart - Order Management for Letzshop Sellers{% endblock %} {% block title %}Wizamart - Order Management for Letzshop Sellers{% endblock %}
@@ -407,7 +407,7 @@ function homepageData() {
this.vendorResult = null; this.vendorResult = null;
try { try {
const response = await fetch('/api/v1/public/letzshop-vendors/lookup', { const response = await fetch('/api/v1/platform/letzshop-vendors/lookup', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: this.shopUrl }) body: JSON.stringify({ url: this.shopUrl })

View File

@@ -1,13 +1,13 @@
# app/modules/core/routes/api/public.py # app/modules/core/routes/api/platform.py
""" """
Public language API endpoints. Platform language API endpoints.
Handles: Handles:
- Setting language preference via cookie - Setting language preference via cookie
- Getting current language info - Getting current language info
- Listing available languages - Listing available languages
All endpoints are public (no authentication required). All endpoints are unauthenticated (no authentication required).
""" """
import logging import logging

View File

@@ -5,12 +5,12 @@ from .page_context import (
get_admin_context, get_admin_context,
get_vendor_context, get_vendor_context,
get_storefront_context, get_storefront_context,
get_public_context, get_platform_context,
) )
__all__ = [ __all__ = [
"get_admin_context", "get_admin_context",
"get_vendor_context", "get_vendor_context",
"get_storefront_context", "get_storefront_context",
"get_public_context", "get_platform_context",
] ]

View File

@@ -253,13 +253,13 @@ def get_storefront_context(
return context return context
def get_public_context( def get_platform_context(
request: Request, request: Request,
db: Session, db: Session,
**extra_context, **extra_context,
) -> dict: ) -> dict:
""" """
Build context for public/marketing pages. Build context for platform/marketing pages.
Includes platform info, i18n globals, and CMS navigation pages. Includes platform info, i18n globals, and CMS navigation pages.

View File

@@ -1,8 +1,8 @@
# app/modules/loyalty/routes/api/public.py # app/modules/loyalty/routes/api/platform.py
""" """
Loyalty module public routes. Loyalty module platform routes.
Public endpoints for: Platform endpoints for:
- Customer enrollment (by vendor code) - Customer enrollment (by vendor code)
- Apple Wallet pass download - Apple Wallet pass download
- Apple Web Service endpoints for device registration/updates - Apple Web Service endpoints for device registration/updates
@@ -29,8 +29,8 @@ from app.modules.loyalty.services import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Public router (no auth required for some endpoints) # Platform router (no auth required for some endpoints)
public_router = APIRouter(prefix="/loyalty") platform_router = APIRouter(prefix="/loyalty")
# ============================================================================= # =============================================================================
@@ -38,7 +38,7 @@ public_router = APIRouter(prefix="/loyalty")
# ============================================================================= # =============================================================================
@public_router.get("/programs/{vendor_code}") @platform_router.get("/programs/{vendor_code}")
def get_program_by_vendor_code( def get_program_by_vendor_code(
vendor_code: str = Path(..., min_length=1, max_length=50), vendor_code: str = Path(..., min_length=1, max_length=50),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -85,7 +85,7 @@ def get_program_by_vendor_code(
# ============================================================================= # =============================================================================
@public_router.get("/passes/apple/{serial_number}.pkpass") @platform_router.get("/passes/apple/{serial_number}.pkpass")
def download_apple_pass( def download_apple_pass(
serial_number: str = Path(...), serial_number: str = Path(...),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -122,7 +122,7 @@ def download_apple_pass(
# ============================================================================= # =============================================================================
@public_router.post("/apple/v1/devices/{device_id}/registrations/{pass_type_id}/{serial_number}") @platform_router.post("/apple/v1/devices/{device_id}/registrations/{pass_type_id}/{serial_number}")
def register_device( def register_device(
device_id: str = Path(...), device_id: str = Path(...),
pass_type_id: str = Path(...), pass_type_id: str = Path(...),
@@ -165,7 +165,7 @@ def register_device(
raise HTTPException(status_code=500) raise HTTPException(status_code=500)
@public_router.delete("/apple/v1/devices/{device_id}/registrations/{pass_type_id}/{serial_number}") @platform_router.delete("/apple/v1/devices/{device_id}/registrations/{pass_type_id}/{serial_number}")
def unregister_device( def unregister_device(
device_id: str = Path(...), device_id: str = Path(...),
pass_type_id: str = Path(...), pass_type_id: str = Path(...),
@@ -205,7 +205,7 @@ def unregister_device(
raise HTTPException(status_code=500) raise HTTPException(status_code=500)
@public_router.get("/apple/v1/devices/{device_id}/registrations/{pass_type_id}") @platform_router.get("/apple/v1/devices/{device_id}/registrations/{pass_type_id}")
def get_serial_numbers( def get_serial_numbers(
device_id: str = Path(...), device_id: str = Path(...),
pass_type_id: str = Path(...), pass_type_id: str = Path(...),
@@ -256,7 +256,7 @@ def get_serial_numbers(
} }
@public_router.get("/apple/v1/passes/{pass_type_id}/{serial_number}") @platform_router.get("/apple/v1/passes/{pass_type_id}/{serial_number}")
def get_latest_pass( def get_latest_pass(
pass_type_id: str = Path(...), pass_type_id: str = Path(...),
serial_number: str = Path(...), serial_number: str = Path(...),
@@ -302,7 +302,7 @@ def get_latest_pass(
) )
@public_router.post("/apple/v1/log") @platform_router.post("/apple/v1/log")
def log_errors(): def log_errors():
""" """
Receive error logs from Apple. Receive error logs from Apple.

View File

@@ -1,11 +1,11 @@
# app/modules/marketplace/routes/api/public.py # app/modules/marketplace/routes/api/platform.py
""" """
Public Letzshop vendor lookup API endpoints. Platform Letzshop vendor lookup API endpoints.
Allows potential vendors to find themselves in the Letzshop marketplace Allows potential vendors to find themselves in the Letzshop marketplace
and claim their shop during signup. and claim their shop during signup.
All endpoints are public (no authentication required). All endpoints are unauthenticated (no authentication required).
""" """
import logging import logging

View File

@@ -1,8 +1,8 @@
# app/modules/marketplace/routes/pages/public.py # app/modules/marketplace/routes/pages/platform.py
""" """
Marketplace Public Page Routes (HTML rendering). Marketplace Platform Page Routes (HTML rendering).
Public (unauthenticated) pages: Platform (unauthenticated) pages:
- Find shop (Letzshop vendor browser) - Find shop (Letzshop vendor browser)
""" """
@@ -11,7 +11,7 @@ from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.modules.core.utils.page_context import get_public_context from app.modules.core.utils.page_context import get_platform_context
from app.templates_config import templates from app.templates_config import templates
router = APIRouter() router = APIRouter()
@@ -32,10 +32,10 @@ async def find_shop_page(
Allows vendors to search for and claim their Letzshop shop. Allows vendors to search for and claim their Letzshop shop.
""" """
context = get_public_context(request, db) context = get_platform_context(request, db)
context["page_title"] = "Find Your Letzshop Shop" context["page_title"] = "Find Your Letzshop Shop"
return templates.TemplateResponse( return templates.TemplateResponse(
"marketplace/public/find-shop.html", "marketplace/platform/find-shop.html",
context, context,
) )

View File

@@ -1,6 +1,6 @@
{# app/modules/marketplace/templates/marketplace/public/find-shop.html #} {# app/modules/marketplace/templates/marketplace/public/find-shop.html #}
{# Letzshop Vendor Finder Page #} {# Letzshop Vendor Finder Page #}
{% extends "public/base.html" %} {% extends "platform/base.html" %}
{% block title %}{{ _("cms.platform.find_shop.title") }} - Wizamart{% endblock %} {% block title %}{{ _("cms.platform.find_shop.title") }} - Wizamart{% endblock %}
@@ -151,7 +151,7 @@ function vendorFinderData() {
this.result = null; this.result = null;
try { try {
const response = await fetch('/api/v1/public/letzshop-vendors/lookup', { const response = await fetch('/api/v1/platform/letzshop-vendors/lookup', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: this.searchQuery }) body: JSON.stringify({ url: this.searchQuery })

View File

@@ -183,9 +183,9 @@ def _discover_routes_in_dir(
"pages_prefix": "/storefront", "pages_prefix": "/storefront",
"include_in_schema": True if route_type == "api" else False, "include_in_schema": True if route_type == "api" else False,
}, },
"public": { "platform": {
"api_prefix": "/api/v1/public", "api_prefix": "/api/v1/platform",
"pages_prefix": "/public", "pages_prefix": "/platform",
"include_in_schema": True, "include_in_schema": True,
}, },
"webhooks": { "webhooks": {
@@ -335,16 +335,16 @@ def get_storefront_api_routes() -> list[RouteInfo]:
return sorted(routes, key=lambda r: r.priority) return sorted(routes, key=lambda r: r.priority)
def get_public_api_routes() -> list[RouteInfo]: def get_platform_api_routes() -> list[RouteInfo]:
""" """
Get public API routes from modules, sorted by priority. Get platform API routes from modules, sorted by priority.
Public routes are unauthenticated endpoints for marketing pages, Platform routes are unauthenticated endpoints for marketing pages,
pricing info, and other public-facing features. pricing info, and other public-facing features.
""" """
routes = [ routes = [
r for r in discover_module_routes() r for r in discover_module_routes()
if r.route_type == "api" and r.frontend == "public" if r.route_type == "api" and r.frontend == "platform"
] ]
return sorted(routes, key=lambda r: r.priority) return sorted(routes, key=lambda r: r.priority)
@@ -363,11 +363,11 @@ def get_webhooks_api_routes() -> list[RouteInfo]:
return sorted(routes, key=lambda r: r.priority) return sorted(routes, key=lambda r: r.priority)
def get_public_page_routes() -> list[RouteInfo]: def get_platform_page_routes() -> list[RouteInfo]:
""" """
Get public (marketing) page routes from modules, sorted by priority. Get platform (marketing) page routes from modules, sorted by priority.
Public pages are unauthenticated marketing pages like: Platform pages are unauthenticated marketing pages like:
- Homepage (/) - Homepage (/)
- Pricing (/pricing) - Pricing (/pricing)
- Signup (/signup) - Signup (/signup)
@@ -379,7 +379,7 @@ def get_public_page_routes() -> list[RouteInfo]:
""" """
routes = [ routes = [
r for r in discover_module_routes() r for r in discover_module_routes()
if r.route_type == "pages" and r.frontend == "public" if r.route_type == "pages" and r.frontend == "platform"
] ]
return sorted(routes, key=lambda r: r.priority) return sorted(routes, key=lambda r: r.priority)
@@ -414,8 +414,8 @@ __all__ = [
"get_admin_api_routes", "get_admin_api_routes",
"get_vendor_api_routes", "get_vendor_api_routes",
"get_storefront_api_routes", "get_storefront_api_routes",
"get_public_api_routes", "get_platform_api_routes",
"get_webhooks_api_routes", "get_webhooks_api_routes",
"get_public_page_routes", "get_platform_page_routes",
"get_storefront_page_routes", "get_storefront_page_routes",
] ]

View File

@@ -135,6 +135,6 @@
</script> </script>
<!-- 6. Login Logic --> <!-- 6. Login Logic -->
<script src="{{ url_for('tenancy_static', path='admin/js/login.js') }}"></script> <script src="{{ url_for('core_static', path='admin/js/login.js') }}"></script>
</body> </body>
</html> </html>

View File

@@ -1,5 +1,5 @@
{# app/templates/public/base.html #} {# app/templates/platform/base.html #}
{# Base template for public pages (homepage, about, faq, pricing, signup, etc.) #} {# Base template for platform pages (homepage, about, faq, pricing, signup, etc.) #}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ current_language|default('en') }}" x-data="platformLayoutData()" x-bind:class="{ 'dark': dark }"> <html lang="{{ current_language|default('en') }}" x-data="platformLayoutData()" x-bind:class="{ 'dark': dark }">
<head> <head>
@@ -42,7 +42,7 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
{# Tailwind CSS v4 (built locally via standalone CLI) #} {# Tailwind CSS v4 (built locally via standalone CLI) #}
<link rel="stylesheet" href="{{ url_for('static', path='public/css/tailwind.output.css') }}"> <link rel="stylesheet" href="{{ url_for('static', path='platform/css/tailwind.output.css') }}">
{# Flag icons for language selector #} {# Flag icons for language selector #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.0.0/css/flag-icons.min.css"/> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.0.0/css/flag-icons.min.css"/>

View File

@@ -93,7 +93,7 @@ POST /api/v1/vendor/auth/login
Body: {"username": "...", "password": "..."} Body: {"username": "...", "password": "..."}
# Customer # Customer
POST /api/v1/public/vendors/{vendor_id}/customers/login POST /api/v1/platform/vendors/{vendor_id}/customers/login
Body: {"username": "...", "password": "..."} Body: {"username": "...", "password": "..."}
``` ```

View File

@@ -229,12 +229,12 @@ In path-based development mode, the full URL includes the vendor code (e.g., `/v
**Login Endpoint:** **Login Endpoint:**
``` ```
POST /api/v1/public/vendors/{vendor_id}/customers/login POST /api/v1/platform/vendors/{vendor_id}/customers/login
``` ```
**Example Request:** **Example Request:**
```bash ```bash
curl -X POST http://localhost:8000/api/v1/public/vendors/1/customers/login \ curl -X POST http://localhost:8000/api/v1/platform/vendors/1/customers/login \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"username":"customer","password":"customer123"}' -d '{"username":"customer","password":"customer123"}'
``` ```
@@ -950,7 +950,7 @@ curl http://localhost:8000/api/v1/admin/vendors \
```bash ```bash
# Login # Login
curl -X POST http://localhost:8000/api/v1/public/vendors/1/customers/login \ curl -X POST http://localhost:8000/api/v1/platform/vendors/1/customers/login \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"username":"customer","password":"customer123"}' -d '{"username":"customer","password":"customer123"}'

View File

@@ -748,7 +748,7 @@ role = Role(
│ Client │ │ Client │
└──────┬──────┘ └──────┬──────┘
│ POST /api/v1/public/vendors/{id}/customers/login │ POST /api/v1/platform/vendors/{id}/customers/login
│ { username, password } │ { username, password }
┌─────────────────────────────┐ ┌─────────────────────────────┐

View File

@@ -814,8 +814,8 @@ X-RateLimit-Reset: 1700000000
**Old Pattern (Deprecated):** **Old Pattern (Deprecated):**
```http ```http
GET /api/v1/public/vendors/{vendor_id}/products GET /api/v1/platform/vendors/{vendor_id}/products
POST /api/v1/public/vendors/auth/{vendor_id}/customers/login POST /api/v1/platform/vendors/auth/{vendor_id}/customers/login
``` ```
**New Pattern (Current):** **New Pattern (Current):**

View File

@@ -7,7 +7,7 @@
## Executive Summary ## Executive Summary
The platform currently has **two parallel API structures** for shop/customer-facing endpoints: The platform currently has **two parallel API structures** for shop/customer-facing endpoints:
1. **Original:** `/api/v1/public/vendors/{vendor_id}/*` 1. **Original:** `/api/v1/platform/vendors/{vendor_id}/*`
2. **New:** `/api/v1/shop/*` 2. **New:** `/api/v1/shop/*`
This divergence creates confusion, maintenance overhead, and potential bugs. This document analyzes the situation and proposes a consolidation strategy. This divergence creates confusion, maintenance overhead, and potential bugs. This document analyzes the situation and proposes a consolidation strategy.
@@ -16,19 +16,19 @@ This divergence creates confusion, maintenance overhead, and potential bugs. Thi
## Current State Analysis ## Current State Analysis
### 1. Original Architecture (`/api/v1/public/vendors/`) ### 1. Original Architecture (`/api/v1/platform/vendors/`)
**Location:** `app/api/v1/public/vendors/` **Location:** `app/api/v1/platform/vendors/`
**Endpoints:** **Endpoints:**
``` ```
GET /api/v1/public/vendors → List active vendors GET /api/v1/platform/vendors → List active vendors
GET /api/v1/public/vendors/{vendor_id}/products → Product catalog GET /api/v1/platform/vendors/{vendor_id}/products → Product catalog
GET /api/v1/public/vendors/{vendor_id}/products/{product_id} → Product detail GET /api/v1/platform/vendors/{vendor_id}/products/{product_id} → Product detail
POST /api/v1/public/vendors/{vendor_id}/cart → Cart operations POST /api/v1/platform/vendors/{vendor_id}/cart → Cart operations
GET /api/v1/public/vendors/{vendor_id}/orders → Customer orders GET /api/v1/platform/vendors/{vendor_id}/orders → Customer orders
POST /api/v1/public/vendors/auth/login → Customer authentication POST /api/v1/platform/vendors/auth/login → Customer authentication
POST /api/v1/public/vendors/auth/register → Customer registration POST /api/v1/platform/vendors/auth/register → Customer registration
``` ```
**Characteristics:** **Characteristics:**
@@ -60,7 +60,7 @@ GET /api/v1/shop/content-pages/{slug} → CMS page content
**Characteristics:** **Characteristics:**
-**Vendor-agnostic URLs:** Clean paths without vendor_id -**Vendor-agnostic URLs:** Clean paths without vendor_id
-**Middleware-driven:** Relies on `VendorContextMiddleware` to inject vendor -**Middleware-driven:** Relies on `VendorContextMiddleware` to inject vendor
-**Simpler URLs:** `/api/v1/shop/products` vs `/api/v1/public/vendors/123/products` -**Simpler URLs:** `/api/v1/shop/products` vs `/api/v1/platform/vendors/123/products`
-**Incomplete:** Only CMS endpoints implemented -**Incomplete:** Only CMS endpoints implemented
-**Divergent:** Not consistent with existing public API -**Divergent:** Not consistent with existing public API
@@ -80,7 +80,7 @@ GET /api/v1/shop/content-pages/{slug} → CMS page content
fetch('/api/v1/shop/content-pages/about') fetch('/api/v1/shop/content-pages/about')
// Products use old pattern // Products use old pattern
fetch('/api/v1/public/vendors/123/products') fetch('/api/v1/platform/vendors/123/products')
``` ```
### Confusion ### Confusion
@@ -143,7 +143,7 @@ Developers must remember:
**Implementation:** **Implementation:**
- Vendor extracted by `VendorContextMiddleware` from request - Vendor extracted by `VendorContextMiddleware` from request
- All endpoints use `request.state.vendor` instead of path parameter - All endpoints use `request.state.vendor` instead of path parameter
- URLs are cleaner: `/api/v1/shop/products` instead of `/api/v1/public/vendors/123/products` - URLs are cleaner: `/api/v1/shop/products` instead of `/api/v1/platform/vendors/123/products`
**Pros:** **Pros:**
- ✅ Clean, consistent API structure - ✅ Clean, consistent API structure
@@ -162,18 +162,18 @@ Developers must remember:
--- ---
### Option 2: Keep `/api/v1/public/vendors/*` and Deprecate `/api/v1/shop/*` ### Option 2: Keep `/api/v1/platform/vendors/*` and Deprecate `/api/v1/shop/*`
**Approach:** Move CMS endpoints to `/api/v1/public/vendors/{vendor_id}/content-pages/*` **Approach:** Move CMS endpoints to `/api/v1/platform/vendors/{vendor_id}/content-pages/*`
**Proposed Changes:** **Proposed Changes:**
``` ```
# Move CMS endpoints # Move CMS endpoints
FROM: /api/v1/shop/content-pages/navigation FROM: /api/v1/shop/content-pages/navigation
TO: /api/v1/public/vendors/{vendor_id}/content-pages/navigation TO: /api/v1/platform/vendors/{vendor_id}/content-pages/navigation
FROM: /api/v1/shop/content-pages/{slug} FROM: /api/v1/shop/content-pages/{slug}
TO: /api/v1/public/vendors/{vendor_id}/content-pages/{slug} TO: /api/v1/platform/vendors/{vendor_id}/content-pages/{slug}
``` ```
**Pros:** **Pros:**
@@ -240,7 +240,7 @@ async def get_products_legacy(vendor_id: int, db: Session = Depends(get_db)):
fetch('/api/v1/shop/products') fetch('/api/v1/shop/products')
// ❌ BAD // ❌ BAD
fetch('/api/v1/public/vendors/123/products') fetch('/api/v1/platform/vendors/123/products')
``` ```
3. **Multi-Tenant Best Practice**: Vendor context should be implicit (from domain/path), not explicit in every API call. 3. **Multi-Tenant Best Practice**: Vendor context should be implicit (from domain/path), not explicit in every API call.
@@ -258,7 +258,7 @@ async def get_products_legacy(vendor_id: int, db: Session = Depends(get_db)):
**Day 1-2: Move Products** **Day 1-2: Move Products**
```bash ```bash
# Copy and adapt # Copy and adapt
app/api/v1/public/vendors/products.py → app/api/v1/shop/products.py app/api/v1/platform/vendors/products.py → app/api/v1/shop/products.py
# Changes: # Changes:
- Remove vendor_id path parameter - Remove vendor_id path parameter
@@ -268,24 +268,24 @@ app/api/v1/public/vendors/products.py → app/api/v1/shop/products.py
**Day 3: Move Cart** **Day 3: Move Cart**
```bash ```bash
app/api/v1/public/vendors/cart.py → app/api/v1/shop/cart.py app/api/v1/platform/vendors/cart.py → app/api/v1/shop/cart.py
``` ```
**Day 4: Move Orders** **Day 4: Move Orders**
```bash ```bash
app/api/v1/public/vendors/orders.py → app/api/v1/shop/orders.py app/api/v1/platform/vendors/orders.py → app/api/v1/shop/orders.py
``` ```
**Day 5: Move Auth** **Day 5: Move Auth**
```bash ```bash
app/api/v1/public/vendors/auth.py → app/api/v1/shop/auth.py app/api/v1/platform/vendors/auth.py → app/api/v1/shop/auth.py
``` ```
### Phase 2: Update Frontend (Week 1) ### Phase 2: Update Frontend (Week 1)
**Templates:** **Templates:**
- Update all `fetch()` calls in shop templates - Update all `fetch()` calls in shop templates
- Change from `/api/v1/public/vendors/${vendorId}/...` to `/api/v1/shop/...` - Change from `/api/v1/platform/vendors/${vendorId}/...` to `/api/v1/shop/...`
**JavaScript:** **JavaScript:**
- Update any shop-related API client code - Update any shop-related API client code
@@ -316,10 +316,10 @@ app/api/v1/public/vendors/auth.py → app/api/v1/shop/auth.py
## Code Examples ## Code Examples
### Before (Current - `/api/v1/public/vendors`) ### Before (Current - `/api/v1/platform/vendors`)
```python ```python
# app/api/v1/public/vendors/products.py # app/api/v1/platform/vendors/products.py
@router.get("/{vendor_id}/products") @router.get("/{vendor_id}/products")
def get_public_product_catalog( def get_public_product_catalog(
vendor_id: int = Path(...), vendor_id: int = Path(...),
@@ -332,7 +332,7 @@ def get_public_product_catalog(
```javascript ```javascript
// Frontend // Frontend
const vendorId = 123; const vendorId = 123;
fetch(`/api/v1/public/vendors/${vendorId}/products`) fetch(`/api/v1/platform/vendors/${vendorId}/products`)
``` ```
### After (Proposed - `/api/v1/shop`) ### After (Proposed - `/api/v1/shop`)
@@ -358,14 +358,14 @@ fetch('/api/v1/shop/products') // Vendor context automatic
## Impact Assessment ## Impact Assessment
### Breaking Changes ### Breaking Changes
- All frontend code calling `/api/v1/public/vendors/*` must update - All frontend code calling `/api/v1/platform/vendors/*` must update
- Mobile apps (if any) must update - Mobile apps (if any) must update
- Third-party integrations (if any) must update - Third-party integrations (if any) must update
### Non-Breaking ### Non-Breaking
- Admin APIs: `/api/v1/admin/*` → No changes - Admin APIs: `/api/v1/admin/*` → No changes
- Vendor APIs: `/api/v1/vendor/*` → No changes - Vendor APIs: `/api/v1/vendor/*` → No changes
- Vendor listing: Keep `/api/v1/public/vendors` (list all vendors for marketplace) - Vendor listing: Keep `/api/v1/platform/vendors` (list all vendors for marketplace)
### Risk Mitigation ### Risk Mitigation
1. **Deprecation Period**: Keep old endpoints for 2-4 weeks 1. **Deprecation Period**: Keep old endpoints for 2-4 weeks
@@ -383,7 +383,7 @@ If full migration is not approved immediately, we can do a **minimal fix** for t
```python ```python
# Move: app/api/v1/shop/content_pages.py # Move: app/api/v1/shop/content_pages.py
# To: app/api/v1/public/vendors/content_pages.py # To: app/api/v1/platform/vendors/content_pages.py
# Update routes: # Update routes:
@router.get("/{vendor_id}/content-pages/navigation") @router.get("/{vendor_id}/content-pages/navigation")
@@ -402,7 +402,7 @@ If full migration is not approved immediately, we can do a **minimal fix** for t
Should we: Should we:
1. ✅ **Consolidate to `/api/v1/shop/*`** (Recommended) 1. ✅ **Consolidate to `/api/v1/shop/*`** (Recommended)
2. ❌ **Keep `/api/v1/public/vendors/*`** and move CMS there 2. ❌ **Keep `/api/v1/platform/vendors/*`** and move CMS there
3. ❌ **Hybrid approach** with both patterns 3. ❌ **Hybrid approach** with both patterns
4. ❌ **Quick fix only** - move CMS, address later 4. ❌ **Quick fix only** - move CMS, address later
@@ -412,7 +412,7 @@ Should we:
## Appendix: Current Endpoint Inventory ## Appendix: Current Endpoint Inventory
### `/api/v1/public/vendors/*` ### `/api/v1/platform/vendors/*`
- ✅ `vendors.py` - Vendor listing - ✅ `vendors.py` - Vendor listing
- ✅ `auth.py` - Customer authentication - ✅ `auth.py` - Customer authentication
- ✅ `products.py` - Product catalog - ✅ `products.py` - Product catalog

View File

@@ -174,15 +174,15 @@ Updated all shop templates to use new API endpoints:
| Template | Old Endpoint | New Endpoint | Status | | Template | Old Endpoint | New Endpoint | Status |
|----------|-------------|--------------|---------| |----------|-------------|--------------|---------|
| `shop/account/login.html` | `/api/v1/public/vendors/${id}/customers/login` | `/api/v1/shop/auth/login` | ✅ Complete | | `shop/account/login.html` | `/api/v1/platform/vendors/${id}/customers/login` | `/api/v1/shop/auth/login` | ✅ Complete |
| `shop/account/register.html` | `/api/v1/public/vendors/${id}/customers/register` | `/api/v1/shop/auth/register` | ✅ Complete | | `shop/account/register.html` | `/api/v1/platform/vendors/${id}/customers/register` | `/api/v1/shop/auth/register` | ✅ Complete |
| `shop/product.html` | `/api/v1/public/vendors/${id}/products/${pid}` | `/api/v1/shop/products/${pid}` | ✅ Complete | | `shop/product.html` | `/api/v1/platform/vendors/${id}/products/${pid}` | `/api/v1/shop/products/${pid}` | ✅ Complete |
| `shop/product.html` | `/api/v1/public/vendors/${id}/products?limit=4` | `/api/v1/shop/products?limit=4` | ✅ Complete | | `shop/product.html` | `/api/v1/platform/vendors/${id}/products?limit=4` | `/api/v1/shop/products?limit=4` | ✅ Complete |
| `shop/product.html` | `/api/v1/public/vendors/${id}/cart/${sid}` | `/api/v1/shop/cart/${sid}` | ✅ Complete | | `shop/product.html` | `/api/v1/platform/vendors/${id}/cart/${sid}` | `/api/v1/shop/cart/${sid}` | ✅ Complete |
| `shop/product.html` | `/api/v1/public/vendors/${id}/cart/${sid}/items` | `/api/v1/shop/cart/${sid}/items` | ✅ Complete | | `shop/product.html` | `/api/v1/platform/vendors/${id}/cart/${sid}/items` | `/api/v1/shop/cart/${sid}/items` | ✅ Complete |
| `shop/cart.html` | `/api/v1/public/vendors/${id}/cart/${sid}` | `/api/v1/shop/cart/${sid}` | ✅ Complete | | `shop/cart.html` | `/api/v1/platform/vendors/${id}/cart/${sid}` | `/api/v1/shop/cart/${sid}` | ✅ Complete |
| `shop/cart.html` | `/api/v1/public/vendors/${id}/cart/${sid}/items/${pid}` (PUT) | `/api/v1/shop/cart/${sid}/items/${pid}` | ✅ Complete | | `shop/cart.html` | `/api/v1/platform/vendors/${id}/cart/${sid}/items/${pid}` (PUT) | `/api/v1/shop/cart/${sid}/items/${pid}` | ✅ Complete |
| `shop/cart.html` | `/api/v1/public/vendors/${id}/cart/${sid}/items/${pid}` (DELETE) | `/api/v1/shop/cart/${sid}/items/${pid}` | ✅ Complete | | `shop/cart.html` | `/api/v1/platform/vendors/${id}/cart/${sid}/items/${pid}` (DELETE) | `/api/v1/shop/cart/${sid}/items/${pid}` | ✅ Complete |
| `shop/products.html` | Already using `/api/v1/shop/products` | (No change needed) | ✅ Already Updated | | `shop/products.html` | Already using `/api/v1/shop/products` | (No change needed) | ✅ Already Updated |
| `shop/home.html` | Already using `/api/v1/shop/products?featured=true` | (No change needed) | ✅ Already Updated | | `shop/home.html` | Already using `/api/v1/shop/products?featured=true` | (No change needed) | ✅ Already Updated |
@@ -196,7 +196,7 @@ grep -r "api/v1/public/vendors" app/templates/shop --include="*.html"
### ✅ Phase 3: Old Endpoint Cleanup (COMPLETE) ### ✅ Phase 3: Old Endpoint Cleanup (COMPLETE)
Cleaned up old `/api/v1/public/vendors/*` endpoints: Cleaned up old `/api/v1/platform/vendors/*` endpoints:
**Files Removed:** **Files Removed:**
-`auth.py` - Migrated to `/api/v1/shop/auth.py` -`auth.py` - Migrated to `/api/v1/shop/auth.py`
@@ -209,14 +209,14 @@ Cleaned up old `/api/v1/public/vendors/*` endpoints:
**Files Kept:** **Files Kept:**
-`vendors.py` - Vendor lookup endpoints (truly public, not shop-specific) -`vendors.py` - Vendor lookup endpoints (truly public, not shop-specific)
- `GET /api/v1/public/vendors/by-code/{vendor_code}` - `GET /api/v1/platform/vendors/by-code/{vendor_code}`
- `GET /api/v1/public/vendors/by-subdomain/{subdomain}` - `GET /api/v1/platform/vendors/by-subdomain/{subdomain}`
- `GET /api/v1/public/vendors/{vendor_id}/info` - `GET /api/v1/platform/vendors/{vendor_id}/info`
**Updated:** **Updated:**
-`/app/api/v1/public/__init__.py` - Now only includes vendor lookup endpoints -`/app/api/v1/platform/__init__.py` - Now only includes vendor lookup endpoints
**Result:** Old shop endpoints completely removed, only vendor lookup remains in `/api/v1/public/vendors/*` **Result:** Old shop endpoints completely removed, only vendor lookup remains in `/api/v1/platform/vendors/*`
### ⚠️ Phase 4: Deprecation Warnings (SKIPPED - Not Needed) ### ⚠️ Phase 4: Deprecation Warnings (SKIPPED - Not Needed)
@@ -252,16 +252,16 @@ Old endpoint cleanup completed immediately (no gradual migration needed):
1. ✅ Removed old endpoint files: 1. ✅ Removed old endpoint files:
```bash ```bash
rm app/api/v1/public/vendors/products.py rm app/api/v1/platform/vendors/products.py
rm app/api/v1/public/vendors/cart.py rm app/api/v1/platform/vendors/cart.py
rm app/api/v1/public/vendors/orders.py rm app/api/v1/platform/vendors/orders.py
rm app/api/v1/public/vendors/auth.py rm app/api/v1/platform/vendors/auth.py
rm app/api/v1/public/vendors/payments.py rm app/api/v1/platform/vendors/payments.py
rm app/api/v1/public/vendors/search.py rm app/api/v1/platform/vendors/search.py
rm app/api/v1/public/vendors/shop.py rm app/api/v1/platform/vendors/shop.py
``` ```
2. ✅ Updated `/api/v1/public/__init__.py`: 2. ✅ Updated `/api/v1/platform/__init__.py`:
```python ```python
# Only import vendor lookup endpoints # Only import vendor lookup endpoints
from .vendors import vendors from .vendors import vendors
@@ -280,12 +280,12 @@ Old endpoint cleanup completed immediately (no gradual migration needed):
### Before (Old Pattern) ### Before (Old Pattern)
``` ```
# Verbose - requires vendor_id everywhere # Verbose - requires vendor_id everywhere
/api/v1/public/vendors/123/products /api/v1/platform/vendors/123/products
/api/v1/public/vendors/123/products/456 /api/v1/platform/vendors/123/products/456
/api/v1/public/vendors/123/cart/abc-session-id /api/v1/platform/vendors/123/cart/abc-session-id
/api/v1/public/vendors/123/cart/abc-session-id/items /api/v1/platform/vendors/123/cart/abc-session-id/items
/api/v1/public/vendors/123/customers/789/orders /api/v1/platform/vendors/123/customers/789/orders
/api/v1/public/vendors/auth/123/customers/login /api/v1/platform/vendors/auth/123/customers/login
``` ```
### After (New Pattern) ### After (New Pattern)

View File

@@ -452,7 +452,7 @@ All frontends communicate with backend via APIs:
- `/api/v1/admin/*` - Admin APIs - `/api/v1/admin/*` - Admin APIs
- `/api/v1/vendor/*` - Vendor APIs - `/api/v1/vendor/*` - Vendor APIs
- `/api/v1/storefront/*` - Storefront APIs - `/api/v1/storefront/*` - Storefront APIs
- `/api/v1/public/*` - Platform APIs - `/api/v1/platform/*` - Platform APIs
**Benefits:** **Benefits:**
- Clear backend contracts - Clear backend contracts

View File

@@ -472,7 +472,7 @@ async def setup_payments(
} }
# app/api/v1/public/vendors/payments.py # app/api/v1/platform/vendors/payments.py
@router.post("/{vendor_id}/payments/create-intent") @router.post("/{vendor_id}/payments/create-intent")
async def create_payment_intent( async def create_payment_intent(
vendor_id: int, vendor_id: int,
@@ -535,7 +535,7 @@ class CheckoutManager {
async initializePayment(orderData) { async initializePayment(orderData) {
// Create payment intent // Create payment intent
const response = await fetch(`/api/v1/public/vendors/${this.vendorId}/payments/create-intent`, { const response = await fetch(`/api/v1/platform/vendors/${this.vendorId}/payments/create-intent`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -585,7 +585,7 @@ Customer proceeds to checkout
System creates Order (payment_status: pending) System creates Order (payment_status: pending)
Frontend calls POST /api/v1/public/vendors/{vendor_id}/payments/create-intent Frontend calls POST /api/v1/platform/vendors/{vendor_id}/payments/create-intent
PaymentService creates Stripe PaymentIntent with vendor destination PaymentService creates Stripe PaymentIntent with vendor destination

View File

@@ -53,7 +53,7 @@ app/api/v1/vendor/
├── inventory.py # Handles inventory items ├── inventory.py # Handles inventory items
└── settings.py # Exception: not a resource collection └── settings.py # Exception: not a resource collection
app/api/v1/public/vendors/ app/api/v1/platform/vendors/
├── products.py # Public product catalog ├── products.py # Public product catalog
├── orders.py # Order placement ├── orders.py # Order placement
└── auth.py # Exception: authentication service └── auth.py # Exception: authentication service

View File

@@ -132,24 +132,24 @@ Standalone page with:
## API Endpoints ## API Endpoints
All endpoints under `/api/v1/public/`: All endpoints under `/api/v1/platform/`:
### Pricing Endpoints ### Pricing Endpoints
``` ```
GET /api/v1/public/tiers GET /api/v1/platform/tiers
Returns all public subscription tiers Returns all public subscription tiers
Response: TierResponse[] Response: TierResponse[]
GET /api/v1/public/tiers/{tier_code} GET /api/v1/platform/tiers/{tier_code}
Returns specific tier by code Returns specific tier by code
Response: TierResponse Response: TierResponse
GET /api/v1/public/addons GET /api/v1/platform/addons
Returns all active add-on products Returns all active add-on products
Response: AddOnResponse[] Response: AddOnResponse[]
GET /api/v1/public/pricing GET /api/v1/platform/pricing
Returns complete pricing info (tiers + addons + trial_days) Returns complete pricing info (tiers + addons + trial_days)
Response: PricingResponse Response: PricingResponse
``` ```
@@ -157,17 +157,17 @@ GET /api/v1/public/pricing
### Letzshop Vendor Endpoints ### Letzshop Vendor Endpoints
``` ```
GET /api/v1/public/letzshop-vendors GET /api/v1/platform/letzshop-vendors
Query params: ?search=&category=&city=&page=1&limit=20 Query params: ?search=&category=&city=&page=1&limit=20
Returns paginated vendor list (placeholder for future) Returns paginated vendor list (placeholder for future)
Response: LetzshopVendorListResponse Response: LetzshopVendorListResponse
POST /api/v1/public/letzshop-vendors/lookup POST /api/v1/platform/letzshop-vendors/lookup
Body: { "url": "letzshop.lu/vendors/my-shop" } Body: { "url": "letzshop.lu/vendors/my-shop" }
Returns vendor info from URL lookup Returns vendor info from URL lookup
Response: LetzshopLookupResponse Response: LetzshopLookupResponse
GET /api/v1/public/letzshop-vendors/{slug} GET /api/v1/platform/letzshop-vendors/{slug}
Returns vendor info by slug Returns vendor info by slug
Response: LetzshopVendorInfo Response: LetzshopVendorInfo
``` ```
@@ -175,17 +175,17 @@ GET /api/v1/public/letzshop-vendors/{slug}
### Signup Endpoints ### Signup Endpoints
``` ```
POST /api/v1/public/signup/start POST /api/v1/platform/signup/start
Body: { "tier_code": "professional", "is_annual": false } Body: { "tier_code": "professional", "is_annual": false }
Creates signup session Creates signup session
Response: { "session_id": "...", "tier_code": "...", "is_annual": false } Response: { "session_id": "...", "tier_code": "...", "is_annual": false }
POST /api/v1/public/signup/claim-vendor POST /api/v1/platform/signup/claim-vendor
Body: { "session_id": "...", "letzshop_slug": "my-shop" } Body: { "session_id": "...", "letzshop_slug": "my-shop" }
Claims Letzshop vendor for session Claims Letzshop vendor for session
Response: { "session_id": "...", "letzshop_slug": "...", "vendor_name": "..." } Response: { "session_id": "...", "letzshop_slug": "...", "vendor_name": "..." }
POST /api/v1/public/signup/create-account POST /api/v1/platform/signup/create-account
Body: { Body: {
"session_id": "...", "session_id": "...",
"email": "user@example.com", "email": "user@example.com",
@@ -197,17 +197,17 @@ POST /api/v1/public/signup/create-account
Creates User, Company, Vendor, Stripe Customer Creates User, Company, Vendor, Stripe Customer
Response: { "session_id": "...", "user_id": 1, "vendor_id": 1, "stripe_customer_id": "cus_..." } Response: { "session_id": "...", "user_id": 1, "vendor_id": 1, "stripe_customer_id": "cus_..." }
POST /api/v1/public/signup/setup-payment POST /api/v1/platform/signup/setup-payment
Body: { "session_id": "..." } Body: { "session_id": "..." }
Creates Stripe SetupIntent Creates Stripe SetupIntent
Response: { "session_id": "...", "client_secret": "seti_...", "stripe_customer_id": "cus_..." } Response: { "session_id": "...", "client_secret": "seti_...", "stripe_customer_id": "cus_..." }
POST /api/v1/public/signup/complete POST /api/v1/platform/signup/complete
Body: { "session_id": "...", "setup_intent_id": "seti_..." } Body: { "session_id": "...", "setup_intent_id": "seti_..." }
Completes signup, attaches payment method Completes signup, attaches payment method
Response: { "success": true, "vendor_code": "...", "vendor_id": 1, "redirect_url": "...", "trial_ends_at": "..." } Response: { "success": true, "vendor_code": "...", "vendor_id": 1, "redirect_url": "...", "trial_ends_at": "..." }
GET /api/v1/public/signup/session/{session_id} GET /api/v1/platform/signup/session/{session_id}
Returns session status for resuming signup Returns session status for resuming signup
Response: { "session_id": "...", "step": "...", ... } Response: { "session_id": "...", "step": "...", ... }
``` ```
@@ -430,7 +430,7 @@ STRIPE_TRIAL_DAYS=30
### Automated Tests ### Automated Tests
Test files located in `tests/integration/api/v1/public/`: Test files located in `tests/integration/api/v1/platform/`:
| File | Tests | Description | | File | Tests | Description |
|------|-------|-------------| |------|-------|-------------|
@@ -440,7 +440,7 @@ Test files located in `tests/integration/api/v1/public/`:
**Run tests:** **Run tests:**
```bash ```bash
pytest tests/integration/api/v1/public/ -v pytest tests/integration/api/v1/platform/ -v
``` ```
**Test categories:** **Test categories:**
@@ -479,15 +479,15 @@ pytest tests/integration/api/v1/public/ -v
```bash ```bash
# Get pricing # Get pricing
curl http://localhost:8000/api/v1/public/pricing curl http://localhost:8000/api/v1/platform/pricing
# Lookup vendor # Lookup vendor
curl -X POST http://localhost:8000/api/v1/public/letzshop-vendors/lookup \ curl -X POST http://localhost:8000/api/v1/platform/letzshop-vendors/lookup \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"url": "letzshop.lu/vendors/test-shop"}' -d '{"url": "letzshop.lu/vendors/test-shop"}'
# Start signup # Start signup
curl -X POST http://localhost:8000/api/v1/public/signup/start \ curl -X POST http://localhost:8000/api/v1/platform/signup/start \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"tier_code": "professional", "is_annual": false}' -d '{"tier_code": "professional", "is_annual": false}'
``` ```

View File

@@ -0,0 +1,653 @@
# Plan: Flexible Role & Permission Management with Platform Controls
## Status: READY FOR APPROVAL
## Summary
Design a flexible role/permission management system that:
1. **Modules define permissions** - Each module declares its available permissions
2. **Platforms control availability** - Platforms can restrict which permissions vendors can use
3. **Vendors customize roles** - Vendors create custom roles within platform constraints
4. **Multi-tier hierarchy** - Platform → Vendor → User permission inheritance
---
## Current State Analysis
### What Exists Today
| Component | Location | Description |
|-----------|----------|-------------|
| **Role Model** | `app/modules/tenancy/models/vendor.py` | `vendor_id`, `name`, `permissions` (JSON array) |
| **VendorUser Model** | Same file | Links user → vendor with `role_id` |
| **PermissionDiscoveryService** | `app/modules/tenancy/services/permission_discovery_service.py` | Discovers permissions from modules |
| **VendorTeamService** | `app/modules/tenancy/services/vendor_team_service.py` | Manages team invitations, role assignment |
| **Role Presets** | In discovery service code | Hardcoded `ROLE_PRESETS` dict |
| **Platform Model** | `models/database/platform.py` | Multi-platform support |
| **PlatformModule** | `models/database/platform_module.py` | Controls which modules are enabled per platform |
| **VendorPlatform** | `models/database/vendor_platform.py` | Vendor-platform relationship with `tier_id` |
### Current Gaps
1. **No platform-level permission control** - Platforms cannot restrict which permissions vendors can assign
2. **No custom role CRUD API** - Roles are created implicitly when inviting team members
3. **Presets are code-only** - Cannot customize role templates per platform
4. **No role templates table** - Platform admins cannot define default roles for their vendors
---
## Proposed Architecture
### Tier 1: Module-Defined Permissions (Exists)
Each module declares permissions in `definition.py`:
```python
permissions=[
PermissionDefinition(
id="products.view",
label_key="catalog.permissions.products_view",
category="products",
),
PermissionDefinition(
id="products.create",
label_key="catalog.permissions.products_create",
category="products",
),
]
```
**Discovery Service** aggregates all permissions at runtime.
### Tier 2: Platform Permission Control (New)
New `PlatformPermissionConfig` model to control:
- Which permissions are available to vendors on this platform
- Default role templates for vendor onboarding
- Permission bundles based on subscription tier
```
┌─────────────────────────────────────────────────────────────────┐
│ PLATFORM │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ PlatformPermissionConfig │ │
│ │ - platform_id │ │
│ │ - allowed_permissions: ["products.*", "orders.*"] │ │
│ │ - blocked_permissions: ["team.manage"] │ │
│ │ - tier_restrictions: {free: [...], pro: [...]} │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ PlatformRoleTemplate │ │
│ │ - platform_id │ │
│ │ - name: "Manager", "Staff", etc. │ │
│ │ - permissions: [...] │ │
│ │ - is_default: bool (create for new vendors) │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### Tier 3: Vendor Role Customization (Enhanced)
Vendors can:
- View roles available (from platform templates or custom)
- Create custom roles (within platform constraints)
- Edit role permissions (within allowed set)
- Assign roles to team members
```
┌─────────────────────────────────────────────────────────────────┐
│ VENDOR │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Role (existing model, enhanced) │ │
│ │ - vendor_id │ │
│ │ - name │ │
│ │ - permissions: [...] (validated against platform) │ │
│ │ - is_from_template: bool │ │
│ │ - source_template_id: FK (nullable) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ VendorUser (existing, unchanged) │ │
│ │ - user_id │ │
│ │ - vendor_id │ │
│ │ - role_id │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Data Model Changes
### New Models
#### 1. PlatformPermissionConfig
```python
# app/modules/tenancy/models/platform_permission_config.py
class PlatformPermissionConfig(Base):
"""Platform-level permission configuration"""
__tablename__ = "platform_permission_configs"
id: Mapped[int] = mapped_column(primary_key=True)
platform_id: Mapped[int] = mapped_column(ForeignKey("platforms.id"), unique=True)
# Permissions this platform allows vendors to use
# Empty = all discovered permissions allowed
allowed_permissions: Mapped[list[str]] = mapped_column(JSON, default=list)
# Explicit blocklist (takes precedence over allowed)
blocked_permissions: Mapped[list[str]] = mapped_column(JSON, default=list)
# Tier-based restrictions: {"free": ["products.view"], "pro": ["products.*"]}
tier_permissions: Mapped[dict] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(onupdate=func.now())
# Relationships
platform: Mapped["Platform"] = relationship(back_populates="permission_config")
```
#### 2. PlatformRoleTemplate
```python
# app/modules/tenancy/models/platform_role_template.py
class PlatformRoleTemplate(Base):
"""Role templates defined at platform level"""
__tablename__ = "platform_role_templates"
id: Mapped[int] = mapped_column(primary_key=True)
platform_id: Mapped[int] = mapped_column(ForeignKey("platforms.id"))
name: Mapped[str] = mapped_column(String(50)) # "Manager", "Staff", etc.
display_name: Mapped[str] = mapped_column(String(100)) # i18n key or display name
description: Mapped[str | None] = mapped_column(String(255))
# Permissions for this template
permissions: Mapped[list[str]] = mapped_column(JSON, default=list)
# Configuration
is_default: Mapped[bool] = mapped_column(default=False) # Auto-create for new vendors
is_system: Mapped[bool] = mapped_column(default=False) # Cannot be deleted
order: Mapped[int] = mapped_column(default=100) # Display order
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(onupdate=func.now())
# Relationships
platform: Mapped["Platform"] = relationship(back_populates="role_templates")
__table_args__ = (
UniqueConstraint("platform_id", "name", name="uq_platform_role_template"),
)
```
### Enhanced Existing Models
#### Role Model Enhancement
```python
# Add to existing Role model in app/modules/tenancy/models/vendor.py
class Role(Base):
# ... existing fields ...
# NEW: Track template origin
source_template_id: Mapped[int | None] = mapped_column(
ForeignKey("platform_role_templates.id"),
nullable=True
)
is_custom: Mapped[bool] = mapped_column(default=False) # Vendor-created custom role
# Relationship
source_template: Mapped["PlatformRoleTemplate"] = relationship()
```
---
## Service Layer Changes
### 1. PlatformPermissionService (New)
```python
# app/modules/tenancy/services/platform_permission_service.py
class PlatformPermissionService:
"""Manages platform-level permission configuration"""
def get_allowed_permissions(
self,
db: Session,
platform_id: int,
tier_id: int | None = None
) -> set[str]:
"""
Get permissions allowed for a platform/tier combination.
1. Start with all discovered permissions
2. Filter by platform's allowed_permissions (if set)
3. Remove blocked_permissions
4. Apply tier restrictions (if tier_id provided)
"""
pass
def validate_permissions(
self,
db: Session,
platform_id: int,
tier_id: int | None,
permissions: list[str]
) -> tuple[list[str], list[str]]:
"""
Validate permissions against platform constraints.
Returns (valid_permissions, invalid_permissions)
"""
pass
def update_platform_config(
self,
db: Session,
platform_id: int,
allowed_permissions: list[str] | None = None,
blocked_permissions: list[str] | None = None,
tier_permissions: dict | None = None
) -> PlatformPermissionConfig:
"""Update platform permission configuration"""
pass
```
### 2. PlatformRoleTemplateService (New)
```python
# app/modules/tenancy/services/platform_role_template_service.py
class PlatformRoleTemplateService:
"""Manages platform role templates"""
def get_templates(self, db: Session, platform_id: int) -> list[PlatformRoleTemplate]:
"""Get all role templates for a platform"""
pass
def create_template(
self,
db: Session,
platform_id: int,
name: str,
permissions: list[str],
is_default: bool = False
) -> PlatformRoleTemplate:
"""Create a new role template (validates permissions)"""
pass
def create_default_roles_for_vendor(
self,
db: Session,
vendor: Vendor
) -> list[Role]:
"""
Create vendor roles from platform's default templates.
Called during vendor onboarding.
"""
pass
def seed_default_templates(self, db: Session, platform_id: int):
"""Seed platform with standard role templates (Manager, Staff, etc.)"""
pass
```
### 3. Enhanced VendorTeamService
```python
# Updates to app/modules/tenancy/services/vendor_team_service.py
class VendorTeamService:
def get_available_permissions(
self,
db: Session,
vendor: Vendor
) -> list[PermissionDefinition]:
"""
Get permissions available to this vendor based on:
1. Platform constraints
2. Vendor's subscription tier
"""
platform_perm_service = PlatformPermissionService()
vendor_platform = db.query(VendorPlatform).filter(...).first()
allowed = platform_perm_service.get_allowed_permissions(
db,
vendor_platform.platform_id,
vendor_platform.tier_id
)
# Return PermissionDefinitions filtered to allowed set
all_perms = permission_discovery_service.get_all_permissions()
return [p for p in all_perms if p.id in allowed]
def create_custom_role(
self,
db: Session,
vendor: Vendor,
name: str,
permissions: list[str]
) -> Role:
"""
Create a custom role for the vendor.
Validates permissions against platform constraints.
"""
# Validate permissions
valid, invalid = self.platform_permission_service.validate_permissions(
db, vendor.platform_id, vendor.tier_id, permissions
)
if invalid:
raise InvalidPermissionsException(invalid)
role = Role(
vendor_id=vendor.id,
name=name,
permissions=valid,
is_custom=True
)
db.add(role)
return role
def update_role(
self,
db: Session,
vendor: Vendor,
role_id: int,
name: str | None = None,
permissions: list[str] | None = None
) -> Role:
"""Update an existing role (validates permissions)"""
pass
def delete_role(
self,
db: Session,
vendor: Vendor,
role_id: int
) -> bool:
"""Delete a custom role (cannot delete if in use)"""
pass
```
---
## API Endpoints
### Platform Admin Endpoints (Admin Panel)
```python
# app/modules/tenancy/routes/admin/platform_permissions.py
@router.get("/platforms/{platform_id}/permissions")
def get_platform_permission_config(platform_id: int):
"""Get platform permission configuration"""
@router.put("/platforms/{platform_id}/permissions")
def update_platform_permission_config(platform_id: int, config: PermissionConfigUpdate):
"""Update platform permission configuration"""
@router.get("/platforms/{platform_id}/role-templates")
def list_role_templates(platform_id: int):
"""List role templates for a platform"""
@router.post("/platforms/{platform_id}/role-templates")
def create_role_template(platform_id: int, template: RoleTemplateCreate):
"""Create a new role template"""
@router.put("/platforms/{platform_id}/role-templates/{template_id}")
def update_role_template(platform_id: int, template_id: int, template: RoleTemplateUpdate):
"""Update a role template"""
@router.delete("/platforms/{platform_id}/role-templates/{template_id}")
def delete_role_template(platform_id: int, template_id: int):
"""Delete a role template"""
```
### Vendor Dashboard Endpoints
```python
# app/modules/tenancy/routes/api/vendor_roles.py
@router.get("/roles")
def list_vendor_roles():
"""List all roles for current vendor"""
@router.post("/roles")
def create_custom_role(role: RoleCreate):
"""Create a custom role (validates permissions against platform)"""
@router.put("/roles/{role_id}")
def update_role(role_id: int, role: RoleUpdate):
"""Update a role"""
@router.delete("/roles/{role_id}")
def delete_role(role_id: int):
"""Delete a custom role"""
@router.get("/available-permissions")
def get_available_permissions():
"""Get permissions available to this vendor (filtered by platform/tier)"""
```
---
## Permission Flow Diagram
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ PERMISSION FLOW │
└─────────────────────────────────────────────────────────────────────────────┘
1. MODULE DEFINES PERMISSIONS
┌──────────────┐
│ catalog │ → products.view, products.create, products.edit, ...
│ orders │ → orders.view, orders.manage, orders.refund, ...
│ team │ → team.view, team.manage, team.invite, ...
└──────────────┘
2. DISCOVERY SERVICE AGGREGATES
┌────────────────────────────────────────────────┐
│ PermissionDiscoveryService │
│ get_all_permissions() → 50+ permissions │
└────────────────────────────────────────────────┘
3. PLATFORM FILTERS PERMISSIONS
┌────────────────────────────────────────────────┐
│ PlatformPermissionConfig │
│ allowed: ["products.*", "orders.view"] │
│ blocked: ["orders.refund"] │
│ tier_permissions: │
│ free: ["products.view", "orders.view"] │
│ pro: ["products.*", "orders.*"] │
└────────────────────────────────────────────────┘
4. VENDOR CREATES/USES ROLES
┌────────────────────────────────────────────────┐
│ Role (vendor-specific) │
│ Manager: [products.*, orders.view] │
│ Staff: [products.view, orders.view] │
└────────────────────────────────────────────────┘
5. USER GETS PERMISSIONS VIA ROLE
┌────────────────────────────────────────────────┐
│ VendorUser │
│ user_id: 123 │
│ role_id: 5 (Staff) │
│ → permissions: [products.view, orders.view] │
└────────────────────────────────────────────────┘
```
---
## Database Migrations
### Migration 1: Add Platform Permission Config
```sql
CREATE TABLE platform_permission_configs (
id SERIAL PRIMARY KEY,
platform_id INTEGER NOT NULL UNIQUE REFERENCES platforms(id),
allowed_permissions JSONB DEFAULT '[]',
blocked_permissions JSONB DEFAULT '[]',
tier_permissions JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP
);
```
### Migration 2: Add Platform Role Templates
```sql
CREATE TABLE platform_role_templates (
id SERIAL PRIMARY KEY,
platform_id INTEGER NOT NULL REFERENCES platforms(id),
name VARCHAR(50) NOT NULL,
display_name VARCHAR(100) NOT NULL,
description VARCHAR(255),
permissions JSONB DEFAULT '[]',
is_default BOOLEAN DEFAULT FALSE,
is_system BOOLEAN DEFAULT FALSE,
"order" INTEGER DEFAULT 100,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP,
UNIQUE (platform_id, name)
);
```
### Migration 3: Enhance Roles Table
```sql
ALTER TABLE roles
ADD COLUMN source_template_id INTEGER REFERENCES platform_role_templates(id),
ADD COLUMN is_custom BOOLEAN DEFAULT FALSE;
```
---
## Implementation Phases
### Phase 1: Data Models (Foundation)
**Files to create:**
- `app/modules/tenancy/models/platform_permission_config.py`
- `app/modules/tenancy/models/platform_role_template.py`
- `migrations/versions/xxx_add_platform_permission_tables.py`
- `migrations/versions/xxx_enhance_roles_table.py`
**Files to modify:**
- `app/modules/tenancy/models/__init__.py` - Export new models
- `app/modules/tenancy/models/vendor.py` - Add Role enhancement
### Phase 2: Service Layer
**Files to create:**
- `app/modules/tenancy/services/platform_permission_service.py`
- `app/modules/tenancy/services/platform_role_template_service.py`
**Files to modify:**
- `app/modules/tenancy/services/vendor_team_service.py` - Add role CRUD, permission validation
### Phase 3: API Endpoints
**Files to create:**
- `app/modules/tenancy/routes/admin/platform_permissions.py`
- `app/modules/tenancy/routes/api/vendor_roles.py`
- `app/modules/tenancy/schemas/platform_permissions.py`
- `app/modules/tenancy/schemas/roles.py`
**Files to modify:**
- `app/modules/tenancy/routes/__init__.py` - Register new routers
### Phase 4: Vendor Onboarding Integration
**Files to modify:**
- `app/modules/tenancy/services/vendor_service.py` - Create default roles from templates during vendor creation
### Phase 5: Admin UI (Optional, Future)
**Files to create/modify:**
- Admin panel for platform permission configuration
- Admin panel for role template management
- Vendor dashboard for custom role management
---
## Verification
1. **App loads:** `python -c "from main import app; print('OK')"`
2. **Migrations run:** `make migrate-up`
3. **Architecture validation:** `python scripts/validate_architecture.py -v`
4. **Unit tests:** Test permission filtering logic
- Platform with no config → all permissions allowed
- Platform with allowed list → only those permissions
- Platform with blocked list → all except blocked
- Tier restrictions → correct subset per tier
5. **Integration tests:**
- Create vendor → gets default roles from platform templates
- Create custom role → validates against platform constraints
- Assign role → user gets correct permissions
- Change tier → available permissions update
6. **API tests:**
- Platform admin can configure permissions
- Vendor owner can create/edit custom roles
- Invalid permissions are rejected
---
## Files Summary
### New Files (9)
| File | Purpose |
|------|---------|
| `app/modules/tenancy/models/platform_permission_config.py` | Platform permission config model |
| `app/modules/tenancy/models/platform_role_template.py` | Platform role template model |
| `app/modules/tenancy/services/platform_permission_service.py` | Platform permission logic |
| `app/modules/tenancy/services/platform_role_template_service.py` | Role template logic |
| `app/modules/tenancy/routes/admin/platform_permissions.py` | Admin API endpoints |
| `app/modules/tenancy/routes/api/vendor_roles.py` | Vendor API endpoints |
| `app/modules/tenancy/schemas/platform_permissions.py` | Pydantic schemas |
| `app/modules/tenancy/schemas/roles.py` | Role schemas |
| `migrations/versions/xxx_platform_permission_tables.py` | Database migration |
### Modified Files (5)
| File | Changes |
|------|---------|
| `app/modules/tenancy/models/__init__.py` | Export new models |
| `app/modules/tenancy/models/vendor.py` | Enhance Role model |
| `app/modules/tenancy/services/vendor_team_service.py` | Add role CRUD, validation |
| `app/modules/tenancy/services/vendor_service.py` | Create default roles on vendor creation |
| `app/modules/tenancy/routes/__init__.py` | Register new routers |
---
## Key Design Decisions
1. **Wildcard support in permissions** - `products.*` matches `products.view`, `products.create`, etc.
2. **Tier inheritance** - Higher tiers include all permissions of lower tiers
3. **Template-based vendor roles** - Default roles created from platform templates, but vendor can customize
4. **Soft validation** - Invalid permissions in existing roles are not automatically removed (audit trail)
5. **Backward compatible** - Existing roles without `source_template_id` continue to work
cl

View File

@@ -31,7 +31,7 @@ The test structure directly mirrors the API code structure:
``` ```
app/api/v1/admin/ → tests/integration/api/v1/admin/ app/api/v1/admin/ → tests/integration/api/v1/admin/
app/api/v1/vendor/ → tests/integration/api/v1/vendor/ app/api/v1/vendor/ → tests/integration/api/v1/vendor/
app/api/v1/public/ → tests/integration/api/v1/public/ app/api/v1/platform/ → tests/integration/api/v1/platform/
app/api/v1/shared/ → tests/integration/api/v1/shared/ app/api/v1/shared/ → tests/integration/api/v1/shared/
``` ```
@@ -83,7 +83,7 @@ Different teams can work in parallel with fewer conflicts:
``` ```
Admin Team: works in tests/integration/api/v1/admin/ Admin Team: works in tests/integration/api/v1/admin/
Vendor Team: works in tests/integration/api/v1/vendor/ Vendor Team: works in tests/integration/api/v1/vendor/
Public Team: works in tests/integration/api/v1/public/ Public Team: works in tests/integration/api/v1/platform/
``` ```
## Running Tests ## Running Tests
@@ -98,7 +98,7 @@ pytest tests/integration/api/v1/vendor/ -v
pytest tests/integration/api/v1/admin/ -v pytest tests/integration/api/v1/admin/ -v
# All public tests # All public tests
pytest tests/integration/api/v1/public/ -v pytest tests/integration/api/v1/platform/ -v
# All shared tests # All shared tests
pytest tests/integration/api/v1/shared/ -v pytest tests/integration/api/v1/shared/ -v
@@ -277,7 +277,7 @@ See [Vendor API Testing Guide](vendor-api-testing.md) for details.
### Public Tests (`public/`) ### Public Tests (`public/`)
Tests for public endpoints at `/api/v1/public/*`: Tests for public endpoints at `/api/v1/platform/*`:
- Product catalog browsing - Product catalog browsing
- Public vendor profiles - Public vendor profiles

18
main.py
View File

@@ -64,7 +64,7 @@ from app.exceptions.handler import setup_exception_handlers
# Module route auto-discovery - all page routes now come from modules # Module route auto-discovery - all page routes now come from modules
from app.modules.routes import ( from app.modules.routes import (
get_admin_page_routes, get_admin_page_routes,
get_public_page_routes, get_platform_page_routes,
get_storefront_page_routes, get_storefront_page_routes,
get_vendor_page_routes, get_vendor_page_routes,
) )
@@ -323,18 +323,18 @@ logger.info("ROUTE REGISTRATION (AUTO-DISCOVERY)")
logger.info("=" * 80) logger.info("=" * 80)
# ============================================================================= # =============================================================================
# PUBLIC PAGES (Marketing pages - homepage, pricing, signup, etc.) # PLATFORM PAGES (Marketing pages - homepage, pricing, signup, etc.)
# ============================================================================= # =============================================================================
# Public pages are served at root level (/) for platform marketing # Platform pages are served at root level (/) for platform marketing
logger.info("Auto-discovering public (marketing) page routes...") logger.info("Auto-discovering platform (marketing) page routes...")
public_page_routes = get_public_page_routes() platform_page_routes = get_platform_page_routes()
logger.info(f" Found {len(public_page_routes)} public page route modules") logger.info(f" Found {len(platform_page_routes)} platform page route modules")
for route_info in public_page_routes: for route_info in platform_page_routes:
logger.info(f" Registering {route_info.module_code} public pages (priority={route_info.priority})") logger.info(f" Registering {route_info.module_code} platform pages (priority={route_info.priority})")
app.include_router( app.include_router(
route_info.router, route_info.router,
prefix="", # Public pages at root prefix="", # Platform pages at root
tags=route_info.tags, tags=route_info.tags,
include_in_schema=route_info.include_in_schema, include_in_schema=route_info.include_in_schema,
) )

View File

@@ -273,7 +273,7 @@ def test_customer_login() -> dict | None:
try: try:
response = requests.post( response = requests.post(
f"{BASE_URL}/api/v1/public/vendors/1/customers/login", f"{BASE_URL}/api/v1/platform/vendors/1/customers/login",
json={"username": "customer", "password": "customer123"}, json={"username": "customer", "password": "customer123"},
) )

View File

@@ -15,7 +15,7 @@ pytest tests/integration/api/v1/ -v
# Run specific area # Run specific area
pytest tests/integration/api/v1/vendor/ -v pytest tests/integration/api/v1/vendor/ -v
pytest tests/integration/api/v1/admin/ -v pytest tests/integration/api/v1/admin/ -v
pytest tests/integration/api/v1/public/ -v pytest tests/integration/api/v1/platform/ -v
pytest tests/integration/api/v1/shared/ -v pytest tests/integration/api/v1/shared/ -v
``` ```

View File

@@ -1,4 +1,4 @@
# Public API Integration Tests # Platform API Integration Tests
## Documentation ## Documentation
@@ -9,8 +9,8 @@ For comprehensive testing documentation, see:
## Quick Start ## Quick Start
```bash ```bash
# Run all public tests # Run all platform tests
pytest tests/integration/api/v1/public/ -v pytest tests/integration/api/v1/platform/ -v
``` ```
## Status ## Status

View File

@@ -1,8 +1,8 @@
# tests/integration/api/v1/public/__init__.py # tests/integration/api/v1/platform/__init__.py
"""Public API integration tests. """Platform API integration tests.
Tests for unauthenticated public endpoints: Tests for unauthenticated platform endpoints:
- /api/v1/public/signup/* - Multi-step signup flow - /api/v1/platform/signup/* - Multi-step signup flow
- /api/v1/public/pricing/* - Subscription tiers and pricing - /api/v1/platform/pricing/* - Subscription tiers and pricing
- /api/v1/public/letzshop-vendors/* - Vendor lookup for signup - /api/v1/platform/letzshop-vendors/* - Vendor lookup for signup
""" """

View File

@@ -1,7 +1,7 @@
# tests/integration/api/v1/public/test_letzshop_vendors.py # tests/integration/api/v1/platform/test_letzshop_vendors.py
"""Integration tests for platform Letzshop vendor lookup API endpoints. """Integration tests for platform Letzshop vendor lookup API endpoints.
Tests the /api/v1/public/letzshop-vendors/* endpoints. Tests the /api/v1/platform/letzshop-vendors/* endpoints.
""" """
import pytest import pytest
@@ -62,15 +62,15 @@ def claimed_vendor(db, test_company):
@pytest.mark.api @pytest.mark.api
@pytest.mark.platform @pytest.mark.platform
class TestLetzshopVendorLookupAPI: class TestLetzshopVendorLookupAPI:
"""Test Letzshop vendor lookup endpoints at /api/v1/public/letzshop-vendors/*.""" """Test Letzshop vendor lookup endpoints at /api/v1/platform/letzshop-vendors/*."""
# ========================================================================= # =========================================================================
# GET /api/v1/public/letzshop-vendors # GET /api/v1/platform/letzshop-vendors
# ========================================================================= # =========================================================================
def test_list_vendors_returns_empty_list(self, client): def test_list_vendors_returns_empty_list(self, client):
"""Test listing vendors returns empty list (placeholder).""" """Test listing vendors returns empty list (placeholder)."""
response = client.get("/api/v1/public/letzshop-vendors") response = client.get("/api/v1/platform/letzshop-vendors")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -83,7 +83,7 @@ class TestLetzshopVendorLookupAPI:
def test_list_vendors_with_pagination(self, client): def test_list_vendors_with_pagination(self, client):
"""Test listing vendors with pagination parameters.""" """Test listing vendors with pagination parameters."""
response = client.get("/api/v1/public/letzshop-vendors?page=2&limit=10") response = client.get("/api/v1/platform/letzshop-vendors?page=2&limit=10")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -92,7 +92,7 @@ class TestLetzshopVendorLookupAPI:
def test_list_vendors_with_search(self, client): def test_list_vendors_with_search(self, client):
"""Test listing vendors with search parameter.""" """Test listing vendors with search parameter."""
response = client.get("/api/v1/public/letzshop-vendors?search=my-shop") response = client.get("/api/v1/platform/letzshop-vendors?search=my-shop")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -101,7 +101,7 @@ class TestLetzshopVendorLookupAPI:
def test_list_vendors_with_filters(self, client): def test_list_vendors_with_filters(self, client):
"""Test listing vendors with category and city filters.""" """Test listing vendors with category and city filters."""
response = client.get( response = client.get(
"/api/v1/public/letzshop-vendors?category=fashion&city=luxembourg" "/api/v1/platform/letzshop-vendors?category=fashion&city=luxembourg"
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -111,17 +111,17 @@ class TestLetzshopVendorLookupAPI:
def test_list_vendors_limit_validation(self, client): def test_list_vendors_limit_validation(self, client):
"""Test that limit parameter is validated.""" """Test that limit parameter is validated."""
# Maximum limit is 50 # Maximum limit is 50
response = client.get("/api/v1/public/letzshop-vendors?limit=100") response = client.get("/api/v1/platform/letzshop-vendors?limit=100")
assert response.status_code == 422 assert response.status_code == 422
# ========================================================================= # =========================================================================
# POST /api/v1/public/letzshop-vendors/lookup # POST /api/v1/platform/letzshop-vendors/lookup
# ========================================================================= # =========================================================================
def test_lookup_vendor_by_full_url(self, client): def test_lookup_vendor_by_full_url(self, client):
"""Test looking up vendor by full Letzshop URL.""" """Test looking up vendor by full Letzshop URL."""
response = client.post( response = client.post(
"/api/v1/public/letzshop-vendors/lookup", "/api/v1/platform/letzshop-vendors/lookup",
json={"url": "https://letzshop.lu/vendors/my-test-shop"}, json={"url": "https://letzshop.lu/vendors/my-test-shop"},
) )
@@ -134,7 +134,7 @@ class TestLetzshopVendorLookupAPI:
def test_lookup_vendor_by_url_with_language(self, client): def test_lookup_vendor_by_url_with_language(self, client):
"""Test looking up vendor by URL with language prefix.""" """Test looking up vendor by URL with language prefix."""
response = client.post( response = client.post(
"/api/v1/public/letzshop-vendors/lookup", "/api/v1/platform/letzshop-vendors/lookup",
json={"url": "https://letzshop.lu/en/vendors/my-shop"}, json={"url": "https://letzshop.lu/en/vendors/my-shop"},
) )
@@ -146,7 +146,7 @@ class TestLetzshopVendorLookupAPI:
def test_lookup_vendor_by_url_without_protocol(self, client): def test_lookup_vendor_by_url_without_protocol(self, client):
"""Test looking up vendor by URL without https://.""" """Test looking up vendor by URL without https://."""
response = client.post( response = client.post(
"/api/v1/public/letzshop-vendors/lookup", "/api/v1/platform/letzshop-vendors/lookup",
json={"url": "letzshop.lu/vendors/test-shop"}, json={"url": "letzshop.lu/vendors/test-shop"},
) )
@@ -158,7 +158,7 @@ class TestLetzshopVendorLookupAPI:
def test_lookup_vendor_by_slug_only(self, client): def test_lookup_vendor_by_slug_only(self, client):
"""Test looking up vendor by slug alone.""" """Test looking up vendor by slug alone."""
response = client.post( response = client.post(
"/api/v1/public/letzshop-vendors/lookup", "/api/v1/platform/letzshop-vendors/lookup",
json={"url": "my-shop-name"}, json={"url": "my-shop-name"},
) )
@@ -170,7 +170,7 @@ class TestLetzshopVendorLookupAPI:
def test_lookup_vendor_normalizes_slug(self, client): def test_lookup_vendor_normalizes_slug(self, client):
"""Test that slug is normalized to lowercase.""" """Test that slug is normalized to lowercase."""
response = client.post( response = client.post(
"/api/v1/public/letzshop-vendors/lookup", "/api/v1/platform/letzshop-vendors/lookup",
json={"url": "https://letzshop.lu/vendors/MY-SHOP-NAME"}, json={"url": "https://letzshop.lu/vendors/MY-SHOP-NAME"},
) )
@@ -181,7 +181,7 @@ class TestLetzshopVendorLookupAPI:
def test_lookup_vendor_shows_claimed_status(self, client, claimed_vendor): def test_lookup_vendor_shows_claimed_status(self, client, claimed_vendor):
"""Test that lookup shows if vendor is already claimed.""" """Test that lookup shows if vendor is already claimed."""
response = client.post( response = client.post(
"/api/v1/public/letzshop-vendors/lookup", "/api/v1/platform/letzshop-vendors/lookup",
json={"url": "claimed-shop"}, json={"url": "claimed-shop"},
) )
@@ -193,7 +193,7 @@ class TestLetzshopVendorLookupAPI:
def test_lookup_vendor_shows_unclaimed_status(self, client): def test_lookup_vendor_shows_unclaimed_status(self, client):
"""Test that lookup shows if vendor is not claimed.""" """Test that lookup shows if vendor is not claimed."""
response = client.post( response = client.post(
"/api/v1/public/letzshop-vendors/lookup", "/api/v1/platform/letzshop-vendors/lookup",
json={"url": "unclaimed-new-shop"}, json={"url": "unclaimed-new-shop"},
) )
@@ -205,7 +205,7 @@ class TestLetzshopVendorLookupAPI:
def test_lookup_vendor_empty_url(self, client): def test_lookup_vendor_empty_url(self, client):
"""Test lookup with empty URL.""" """Test lookup with empty URL."""
response = client.post( response = client.post(
"/api/v1/public/letzshop-vendors/lookup", "/api/v1/platform/letzshop-vendors/lookup",
json={"url": ""}, json={"url": ""},
) )
@@ -217,7 +217,7 @@ class TestLetzshopVendorLookupAPI:
def test_lookup_vendor_response_has_expected_fields(self, client): def test_lookup_vendor_response_has_expected_fields(self, client):
"""Test that vendor lookup response has all expected fields.""" """Test that vendor lookup response has all expected fields."""
response = client.post( response = client.post(
"/api/v1/public/letzshop-vendors/lookup", "/api/v1/platform/letzshop-vendors/lookup",
json={"url": "test-vendor"}, json={"url": "test-vendor"},
) )
@@ -230,12 +230,12 @@ class TestLetzshopVendorLookupAPI:
assert "is_claimed" in vendor assert "is_claimed" in vendor
# ========================================================================= # =========================================================================
# GET /api/v1/public/letzshop-vendors/{slug} # GET /api/v1/platform/letzshop-vendors/{slug}
# ========================================================================= # =========================================================================
def test_get_vendor_by_slug(self, client): def test_get_vendor_by_slug(self, client):
"""Test getting vendor by slug.""" """Test getting vendor by slug."""
response = client.get("/api/v1/public/letzshop-vendors/my-shop") response = client.get("/api/v1/platform/letzshop-vendors/my-shop")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -246,7 +246,7 @@ class TestLetzshopVendorLookupAPI:
def test_get_vendor_normalizes_slug(self, client): def test_get_vendor_normalizes_slug(self, client):
"""Test that get vendor normalizes slug to lowercase.""" """Test that get vendor normalizes slug to lowercase."""
response = client.get("/api/v1/public/letzshop-vendors/MY-SHOP") response = client.get("/api/v1/platform/letzshop-vendors/MY-SHOP")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -254,7 +254,7 @@ class TestLetzshopVendorLookupAPI:
def test_get_claimed_vendor_shows_status(self, client, claimed_vendor): def test_get_claimed_vendor_shows_status(self, client, claimed_vendor):
"""Test that get vendor shows claimed status correctly.""" """Test that get vendor shows claimed status correctly."""
response = client.get("/api/v1/public/letzshop-vendors/claimed-shop") response = client.get("/api/v1/platform/letzshop-vendors/claimed-shop")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -262,7 +262,7 @@ class TestLetzshopVendorLookupAPI:
def test_get_unclaimed_vendor_shows_status(self, client): def test_get_unclaimed_vendor_shows_status(self, client):
"""Test that get vendor shows unclaimed status correctly.""" """Test that get vendor shows unclaimed status correctly."""
response = client.get("/api/v1/public/letzshop-vendors/new-unclaimed-shop") response = client.get("/api/v1/platform/letzshop-vendors/new-unclaimed-shop")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -278,7 +278,7 @@ class TestLetzshopSlugExtraction:
def test_extract_from_full_https_url(self, client): def test_extract_from_full_https_url(self, client):
"""Test extraction from https://letzshop.lu/vendors/slug.""" """Test extraction from https://letzshop.lu/vendors/slug."""
response = client.post( response = client.post(
"/api/v1/public/letzshop-vendors/lookup", "/api/v1/platform/letzshop-vendors/lookup",
json={"url": "https://letzshop.lu/vendors/cafe-luxembourg"}, json={"url": "https://letzshop.lu/vendors/cafe-luxembourg"},
) )
assert response.json()["vendor"]["slug"] == "cafe-luxembourg" assert response.json()["vendor"]["slug"] == "cafe-luxembourg"
@@ -286,7 +286,7 @@ class TestLetzshopSlugExtraction:
def test_extract_from_http_url(self, client): def test_extract_from_http_url(self, client):
"""Test extraction from http://letzshop.lu/vendors/slug.""" """Test extraction from http://letzshop.lu/vendors/slug."""
response = client.post( response = client.post(
"/api/v1/public/letzshop-vendors/lookup", "/api/v1/platform/letzshop-vendors/lookup",
json={"url": "http://letzshop.lu/vendors/my-shop"}, json={"url": "http://letzshop.lu/vendors/my-shop"},
) )
assert response.json()["vendor"]["slug"] == "my-shop" assert response.json()["vendor"]["slug"] == "my-shop"
@@ -294,7 +294,7 @@ class TestLetzshopSlugExtraction:
def test_extract_from_url_with_trailing_slash(self, client): def test_extract_from_url_with_trailing_slash(self, client):
"""Test extraction from URL with trailing slash.""" """Test extraction from URL with trailing slash."""
response = client.post( response = client.post(
"/api/v1/public/letzshop-vendors/lookup", "/api/v1/platform/letzshop-vendors/lookup",
json={"url": "https://letzshop.lu/vendors/my-shop/"}, json={"url": "https://letzshop.lu/vendors/my-shop/"},
) )
assert response.json()["vendor"]["slug"] == "my-shop" assert response.json()["vendor"]["slug"] == "my-shop"
@@ -302,7 +302,7 @@ class TestLetzshopSlugExtraction:
def test_extract_from_url_with_query_params(self, client): def test_extract_from_url_with_query_params(self, client):
"""Test extraction from URL with query parameters.""" """Test extraction from URL with query parameters."""
response = client.post( response = client.post(
"/api/v1/public/letzshop-vendors/lookup", "/api/v1/platform/letzshop-vendors/lookup",
json={"url": "https://letzshop.lu/vendors/my-shop?ref=google"}, json={"url": "https://letzshop.lu/vendors/my-shop?ref=google"},
) )
assert response.json()["vendor"]["slug"] == "my-shop" assert response.json()["vendor"]["slug"] == "my-shop"
@@ -310,7 +310,7 @@ class TestLetzshopSlugExtraction:
def test_extract_from_french_url(self, client): def test_extract_from_french_url(self, client):
"""Test extraction from French language URL.""" """Test extraction from French language URL."""
response = client.post( response = client.post(
"/api/v1/public/letzshop-vendors/lookup", "/api/v1/platform/letzshop-vendors/lookup",
json={"url": "https://letzshop.lu/fr/vendors/boulangerie-paul"}, json={"url": "https://letzshop.lu/fr/vendors/boulangerie-paul"},
) )
assert response.json()["vendor"]["slug"] == "boulangerie-paul" assert response.json()["vendor"]["slug"] == "boulangerie-paul"
@@ -318,7 +318,7 @@ class TestLetzshopSlugExtraction:
def test_extract_from_german_url(self, client): def test_extract_from_german_url(self, client):
"""Test extraction from German language URL.""" """Test extraction from German language URL."""
response = client.post( response = client.post(
"/api/v1/public/letzshop-vendors/lookup", "/api/v1/platform/letzshop-vendors/lookup",
json={"url": "https://letzshop.lu/de/vendors/backerei-muller"}, json={"url": "https://letzshop.lu/de/vendors/backerei-muller"},
) )
assert response.json()["vendor"]["slug"] == "backerei-muller" assert response.json()["vendor"]["slug"] == "backerei-muller"

View File

@@ -1,7 +1,7 @@
# tests/integration/api/v1/public/test_pricing.py # tests/integration/api/v1/platform/test_pricing.py
"""Integration tests for platform pricing API endpoints. """Integration tests for platform pricing API endpoints.
Tests the /api/v1/public/pricing/* endpoints. Tests the /api/v1/platform/pricing/* endpoints.
""" """
import pytest import pytest
@@ -18,15 +18,15 @@ from app.modules.billing.models import (
@pytest.mark.api @pytest.mark.api
@pytest.mark.platform @pytest.mark.platform
class TestPlatformPricingAPI: class TestPlatformPricingAPI:
"""Test platform pricing endpoints at /api/v1/public/*.""" """Test platform pricing endpoints at /api/v1/platform/*."""
# ========================================================================= # =========================================================================
# GET /api/v1/public/tiers # GET /api/v1/platform/pricing/tiers
# ========================================================================= # =========================================================================
def test_get_tiers_returns_all_public_tiers(self, client): def test_get_tiers_returns_all_public_tiers(self, client):
"""Test getting all subscription tiers.""" """Test getting all subscription tiers."""
response = client.get("/api/v1/public/tiers") response = client.get("/api/v1/platform/pricing/tiers")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -35,7 +35,7 @@ class TestPlatformPricingAPI:
def test_get_tiers_has_expected_fields(self, client): def test_get_tiers_has_expected_fields(self, client):
"""Test that tier response has all expected fields.""" """Test that tier response has all expected fields."""
response = client.get("/api/v1/public/tiers") response = client.get("/api/v1/platform/pricing/tiers")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -55,7 +55,7 @@ class TestPlatformPricingAPI:
def test_get_tiers_includes_essential(self, client): def test_get_tiers_includes_essential(self, client):
"""Test that Essential tier is included.""" """Test that Essential tier is included."""
response = client.get("/api/v1/public/tiers") response = client.get("/api/v1/platform/pricing/tiers")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -64,7 +64,7 @@ class TestPlatformPricingAPI:
def test_get_tiers_includes_professional(self, client): def test_get_tiers_includes_professional(self, client):
"""Test that Professional tier is included and marked as popular.""" """Test that Professional tier is included and marked as popular."""
response = client.get("/api/v1/public/tiers") response = client.get("/api/v1/platform/pricing/tiers")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -77,7 +77,7 @@ class TestPlatformPricingAPI:
def test_get_tiers_includes_enterprise(self, client): def test_get_tiers_includes_enterprise(self, client):
"""Test that Enterprise tier is included and marked appropriately.""" """Test that Enterprise tier is included and marked appropriately."""
response = client.get("/api/v1/public/tiers") response = client.get("/api/v1/platform/pricing/tiers")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -108,7 +108,7 @@ class TestPlatformPricingAPI:
db.add(tier) db.add(tier)
db.commit() db.commit()
response = client.get("/api/v1/public/tiers") response = client.get("/api/v1/platform/pricing/tiers")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -116,12 +116,12 @@ class TestPlatformPricingAPI:
assert "test_tier" in tier_codes assert "test_tier" in tier_codes
# ========================================================================= # =========================================================================
# GET /api/v1/public/tiers/{tier_code} # GET /api/v1/platform/pricing/tiers/{tier_code}
# ========================================================================= # =========================================================================
def test_get_tier_by_code_success(self, client): def test_get_tier_by_code_success(self, client):
"""Test getting a specific tier by code.""" """Test getting a specific tier by code."""
response = client.get(f"/api/v1/public/tiers/{TierCode.PROFESSIONAL.value}") response = client.get(f"/api/v1/platform/pricing/tiers/{TierCode.PROFESSIONAL.value}")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -130,7 +130,7 @@ class TestPlatformPricingAPI:
def test_get_tier_by_code_essential(self, client): def test_get_tier_by_code_essential(self, client):
"""Test getting Essential tier details.""" """Test getting Essential tier details."""
response = client.get(f"/api/v1/public/tiers/{TierCode.ESSENTIAL.value}") response = client.get(f"/api/v1/platform/pricing/tiers/{TierCode.ESSENTIAL.value}")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -139,19 +139,19 @@ class TestPlatformPricingAPI:
def test_get_tier_by_code_not_found(self, client): def test_get_tier_by_code_not_found(self, client):
"""Test getting a non-existent tier returns 404.""" """Test getting a non-existent tier returns 404."""
response = client.get("/api/v1/public/tiers/nonexistent_tier") response = client.get("/api/v1/platform/pricing/tiers/nonexistent_tier")
assert response.status_code == 404 assert response.status_code == 404
data = response.json() data = response.json()
assert "not found" in data["message"].lower() assert "not found" in data["message"].lower()
# ========================================================================= # =========================================================================
# GET /api/v1/public/addons # GET /api/v1/platform/pricing/addons
# ========================================================================= # =========================================================================
def test_get_addons_empty_when_none_configured(self, client): def test_get_addons_empty_when_none_configured(self, client):
"""Test getting add-ons when none are configured.""" """Test getting add-ons when none are configured."""
response = client.get("/api/v1/public/addons") response = client.get("/api/v1/platform/pricing/addons")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -173,7 +173,7 @@ class TestPlatformPricingAPI:
db.add(addon) db.add(addon)
db.commit() db.commit()
response = client.get("/api/v1/public/addons") response = client.get("/api/v1/platform/pricing/addons")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -197,7 +197,7 @@ class TestPlatformPricingAPI:
db.add(addon) db.add(addon)
db.commit() db.commit()
response = client.get("/api/v1/public/addons") response = client.get("/api/v1/platform/pricing/addons")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -236,7 +236,7 @@ class TestPlatformPricingAPI:
db.add_all([active_addon, inactive_addon]) db.add_all([active_addon, inactive_addon])
db.commit() db.commit()
response = client.get("/api/v1/public/addons") response = client.get("/api/v1/platform/pricing/addons")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -245,12 +245,12 @@ class TestPlatformPricingAPI:
assert "inactive_addon" not in addon_codes assert "inactive_addon" not in addon_codes
# ========================================================================= # =========================================================================
# GET /api/v1/public/pricing # GET /api/v1/platform/pricing
# ========================================================================= # =========================================================================
def test_get_pricing_returns_complete_info(self, client): def test_get_pricing_returns_complete_info(self, client):
"""Test getting complete pricing information.""" """Test getting complete pricing information."""
response = client.get("/api/v1/public/pricing") response = client.get("/api/v1/platform/pricing")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -262,7 +262,7 @@ class TestPlatformPricingAPI:
def test_get_pricing_includes_trial_days(self, client): def test_get_pricing_includes_trial_days(self, client):
"""Test that pricing includes correct trial period.""" """Test that pricing includes correct trial period."""
response = client.get("/api/v1/public/pricing") response = client.get("/api/v1/platform/pricing")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -270,7 +270,7 @@ class TestPlatformPricingAPI:
def test_get_pricing_includes_annual_discount(self, client): def test_get_pricing_includes_annual_discount(self, client):
"""Test that pricing includes annual discount info.""" """Test that pricing includes annual discount info."""
response = client.get("/api/v1/public/pricing") response = client.get("/api/v1/platform/pricing")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -278,7 +278,7 @@ class TestPlatformPricingAPI:
def test_get_pricing_tiers_not_empty(self, client): def test_get_pricing_tiers_not_empty(self, client):
"""Test that pricing always includes tiers.""" """Test that pricing always includes tiers."""
response = client.get("/api/v1/public/pricing") response = client.get("/api/v1/platform/pricing")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()

View File

@@ -1,7 +1,7 @@
# tests/integration/api/v1/public/test_signup.py # tests/integration/api/v1/platform/test_signup.py
"""Integration tests for platform signup API endpoints. """Integration tests for platform signup API endpoints.
Tests the /api/v1/public/signup/* endpoints. Tests the /api/v1/platform/signup/* endpoints.
""" """
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -37,7 +37,7 @@ def mock_stripe_service():
def signup_session(client): def signup_session(client):
"""Create a signup session for testing.""" """Create a signup session for testing."""
response = client.post( response = client.post(
"/api/v1/public/signup/start", "/api/v1/platform/signup/start",
json={"tier_code": TierCode.PROFESSIONAL.value, "is_annual": False}, json={"tier_code": TierCode.PROFESSIONAL.value, "is_annual": False},
) )
return response.json()["session_id"] return response.json()["session_id"]
@@ -104,12 +104,12 @@ def claimed_letzshop_vendor(db, claimed_owner_user):
@pytest.mark.api @pytest.mark.api
@pytest.mark.platform @pytest.mark.platform
class TestSignupStartAPI: class TestSignupStartAPI:
"""Test signup start endpoint at /api/v1/public/signup/start.""" """Test signup start endpoint at /api/v1/platform/signup/start."""
def test_start_signup_success(self, client): def test_start_signup_success(self, client):
"""Test starting a signup session.""" """Test starting a signup session."""
response = client.post( response = client.post(
"/api/v1/public/signup/start", "/api/v1/platform/signup/start",
json={"tier_code": TierCode.ESSENTIAL.value, "is_annual": False}, json={"tier_code": TierCode.ESSENTIAL.value, "is_annual": False},
) )
@@ -122,7 +122,7 @@ class TestSignupStartAPI:
def test_start_signup_with_annual_billing(self, client): def test_start_signup_with_annual_billing(self, client):
"""Test starting signup with annual billing.""" """Test starting signup with annual billing."""
response = client.post( response = client.post(
"/api/v1/public/signup/start", "/api/v1/platform/signup/start",
json={"tier_code": TierCode.PROFESSIONAL.value, "is_annual": True}, json={"tier_code": TierCode.PROFESSIONAL.value, "is_annual": True},
) )
@@ -134,7 +134,7 @@ class TestSignupStartAPI:
"""Test starting signup for all valid tiers.""" """Test starting signup for all valid tiers."""
for tier in [TierCode.ESSENTIAL, TierCode.PROFESSIONAL, TierCode.BUSINESS, TierCode.ENTERPRISE]: for tier in [TierCode.ESSENTIAL, TierCode.PROFESSIONAL, TierCode.BUSINESS, TierCode.ENTERPRISE]:
response = client.post( response = client.post(
"/api/v1/public/signup/start", "/api/v1/platform/signup/start",
json={"tier_code": tier.value, "is_annual": False}, json={"tier_code": tier.value, "is_annual": False},
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -143,7 +143,7 @@ class TestSignupStartAPI:
def test_start_signup_invalid_tier(self, client): def test_start_signup_invalid_tier(self, client):
"""Test starting signup with invalid tier code.""" """Test starting signup with invalid tier code."""
response = client.post( response = client.post(
"/api/v1/public/signup/start", "/api/v1/platform/signup/start",
json={"tier_code": "invalid_tier", "is_annual": False}, json={"tier_code": "invalid_tier", "is_annual": False},
) )
@@ -154,11 +154,11 @@ class TestSignupStartAPI:
def test_start_signup_session_id_is_unique(self, client): def test_start_signup_session_id_is_unique(self, client):
"""Test that each signup session gets a unique ID.""" """Test that each signup session gets a unique ID."""
response1 = client.post( response1 = client.post(
"/api/v1/public/signup/start", "/api/v1/platform/signup/start",
json={"tier_code": TierCode.ESSENTIAL.value, "is_annual": False}, json={"tier_code": TierCode.ESSENTIAL.value, "is_annual": False},
) )
response2 = client.post( response2 = client.post(
"/api/v1/public/signup/start", "/api/v1/platform/signup/start",
json={"tier_code": TierCode.ESSENTIAL.value, "is_annual": False}, json={"tier_code": TierCode.ESSENTIAL.value, "is_annual": False},
) )
@@ -169,12 +169,12 @@ class TestSignupStartAPI:
@pytest.mark.api @pytest.mark.api
@pytest.mark.platform @pytest.mark.platform
class TestClaimVendorAPI: class TestClaimVendorAPI:
"""Test claim vendor endpoint at /api/v1/public/signup/claim-vendor.""" """Test claim vendor endpoint at /api/v1/platform/signup/claim-vendor."""
def test_claim_vendor_success(self, client, signup_session): def test_claim_vendor_success(self, client, signup_session):
"""Test claiming a Letzshop vendor.""" """Test claiming a Letzshop vendor."""
response = client.post( response = client.post(
"/api/v1/public/signup/claim-vendor", "/api/v1/platform/signup/claim-vendor",
json={ json={
"session_id": signup_session, "session_id": signup_session,
"letzshop_slug": "my-new-shop", "letzshop_slug": "my-new-shop",
@@ -190,7 +190,7 @@ class TestClaimVendorAPI:
def test_claim_vendor_with_vendor_id(self, client, signup_session): def test_claim_vendor_with_vendor_id(self, client, signup_session):
"""Test claiming vendor with Letzshop vendor ID.""" """Test claiming vendor with Letzshop vendor ID."""
response = client.post( response = client.post(
"/api/v1/public/signup/claim-vendor", "/api/v1/platform/signup/claim-vendor",
json={ json={
"session_id": signup_session, "session_id": signup_session,
"letzshop_slug": "my-shop", "letzshop_slug": "my-shop",
@@ -205,7 +205,7 @@ class TestClaimVendorAPI:
def test_claim_vendor_invalid_session(self, client): def test_claim_vendor_invalid_session(self, client):
"""Test claiming vendor with invalid session.""" """Test claiming vendor with invalid session."""
response = client.post( response = client.post(
"/api/v1/public/signup/claim-vendor", "/api/v1/platform/signup/claim-vendor",
json={ json={
"session_id": "invalid_session_id", "session_id": "invalid_session_id",
"letzshop_slug": "my-shop", "letzshop_slug": "my-shop",
@@ -219,7 +219,7 @@ class TestClaimVendorAPI:
def test_claim_vendor_already_claimed(self, client, signup_session, claimed_letzshop_vendor): def test_claim_vendor_already_claimed(self, client, signup_session, claimed_letzshop_vendor):
"""Test claiming a vendor that's already claimed.""" """Test claiming a vendor that's already claimed."""
response = client.post( response = client.post(
"/api/v1/public/signup/claim-vendor", "/api/v1/platform/signup/claim-vendor",
json={ json={
"session_id": signup_session, "session_id": signup_session,
"letzshop_slug": "already-claimed-shop", "letzshop_slug": "already-claimed-shop",
@@ -235,12 +235,12 @@ class TestClaimVendorAPI:
@pytest.mark.api @pytest.mark.api
@pytest.mark.platform @pytest.mark.platform
class TestCreateAccountAPI: class TestCreateAccountAPI:
"""Test create account endpoint at /api/v1/public/signup/create-account.""" """Test create account endpoint at /api/v1/platform/signup/create-account."""
def test_create_account_success(self, client, signup_session, mock_stripe_service): def test_create_account_success(self, client, signup_session, mock_stripe_service):
"""Test creating an account.""" """Test creating an account."""
response = client.post( response = client.post(
"/api/v1/public/signup/create-account", "/api/v1/platform/signup/create-account",
json={ json={
"session_id": signup_session, "session_id": signup_session,
"email": "newuser@example.com", "email": "newuser@example.com",
@@ -261,7 +261,7 @@ class TestCreateAccountAPI:
def test_create_account_with_phone(self, client, signup_session, mock_stripe_service): def test_create_account_with_phone(self, client, signup_session, mock_stripe_service):
"""Test creating an account with phone number.""" """Test creating an account with phone number."""
response = client.post( response = client.post(
"/api/v1/public/signup/create-account", "/api/v1/platform/signup/create-account",
json={ json={
"session_id": signup_session, "session_id": signup_session,
"email": "user2@example.com", "email": "user2@example.com",
@@ -280,7 +280,7 @@ class TestCreateAccountAPI:
def test_create_account_invalid_session(self, client, mock_stripe_service): def test_create_account_invalid_session(self, client, mock_stripe_service):
"""Test creating account with invalid session.""" """Test creating account with invalid session."""
response = client.post( response = client.post(
"/api/v1/public/signup/create-account", "/api/v1/platform/signup/create-account",
json={ json={
"session_id": "invalid_session", "session_id": "invalid_session",
"email": "test@example.com", "email": "test@example.com",
@@ -298,7 +298,7 @@ class TestCreateAccountAPI:
): ):
"""Test creating account with existing email.""" """Test creating account with existing email."""
response = client.post( response = client.post(
"/api/v1/public/signup/create-account", "/api/v1/platform/signup/create-account",
json={ json={
"session_id": signup_session, "session_id": signup_session,
"email": "existing@example.com", "email": "existing@example.com",
@@ -316,7 +316,7 @@ class TestCreateAccountAPI:
def test_create_account_invalid_email(self, client, signup_session): def test_create_account_invalid_email(self, client, signup_session):
"""Test creating account with invalid email format.""" """Test creating account with invalid email format."""
response = client.post( response = client.post(
"/api/v1/public/signup/create-account", "/api/v1/platform/signup/create-account",
json={ json={
"session_id": signup_session, "session_id": signup_session,
"email": "not-an-email", "email": "not-an-email",
@@ -333,14 +333,14 @@ class TestCreateAccountAPI:
"""Test creating account after claiming Letzshop vendor.""" """Test creating account after claiming Letzshop vendor."""
# Start signup # Start signup
start_response = client.post( start_response = client.post(
"/api/v1/public/signup/start", "/api/v1/platform/signup/start",
json={"tier_code": TierCode.PROFESSIONAL.value, "is_annual": False}, json={"tier_code": TierCode.PROFESSIONAL.value, "is_annual": False},
) )
session_id = start_response.json()["session_id"] session_id = start_response.json()["session_id"]
# Claim vendor # Claim vendor
client.post( client.post(
"/api/v1/public/signup/claim-vendor", "/api/v1/platform/signup/claim-vendor",
json={ json={
"session_id": session_id, "session_id": session_id,
"letzshop_slug": "my-shop-claim", "letzshop_slug": "my-shop-claim",
@@ -349,7 +349,7 @@ class TestCreateAccountAPI:
# Create account # Create account
response = client.post( response = client.post(
"/api/v1/public/signup/create-account", "/api/v1/platform/signup/create-account",
json={ json={
"session_id": session_id, "session_id": session_id,
"email": "shop@example.com", "email": "shop@example.com",
@@ -369,13 +369,13 @@ class TestCreateAccountAPI:
@pytest.mark.api @pytest.mark.api
@pytest.mark.platform @pytest.mark.platform
class TestSetupPaymentAPI: class TestSetupPaymentAPI:
"""Test setup payment endpoint at /api/v1/public/signup/setup-payment.""" """Test setup payment endpoint at /api/v1/platform/signup/setup-payment."""
def test_setup_payment_success(self, client, signup_session, mock_stripe_service): def test_setup_payment_success(self, client, signup_session, mock_stripe_service):
"""Test setting up payment after account creation.""" """Test setting up payment after account creation."""
# Create account first # Create account first
client.post( client.post(
"/api/v1/public/signup/create-account", "/api/v1/platform/signup/create-account",
json={ json={
"session_id": signup_session, "session_id": signup_session,
"email": "payment@example.com", "email": "payment@example.com",
@@ -388,7 +388,7 @@ class TestSetupPaymentAPI:
# Setup payment # Setup payment
response = client.post( response = client.post(
"/api/v1/public/signup/setup-payment", "/api/v1/platform/signup/setup-payment",
json={"session_id": signup_session}, json={"session_id": signup_session},
) )
@@ -401,7 +401,7 @@ class TestSetupPaymentAPI:
def test_setup_payment_invalid_session(self, client, mock_stripe_service): def test_setup_payment_invalid_session(self, client, mock_stripe_service):
"""Test setup payment with invalid session.""" """Test setup payment with invalid session."""
response = client.post( response = client.post(
"/api/v1/public/signup/setup-payment", "/api/v1/platform/signup/setup-payment",
json={"session_id": "invalid_session"}, json={"session_id": "invalid_session"},
) )
@@ -410,7 +410,7 @@ class TestSetupPaymentAPI:
def test_setup_payment_without_account(self, client, signup_session, mock_stripe_service): def test_setup_payment_without_account(self, client, signup_session, mock_stripe_service):
"""Test setup payment without creating account first.""" """Test setup payment without creating account first."""
response = client.post( response = client.post(
"/api/v1/public/signup/setup-payment", "/api/v1/platform/signup/setup-payment",
json={"session_id": signup_session}, json={"session_id": signup_session},
) )
@@ -423,13 +423,13 @@ class TestSetupPaymentAPI:
@pytest.mark.api @pytest.mark.api
@pytest.mark.platform @pytest.mark.platform
class TestCompleteSignupAPI: class TestCompleteSignupAPI:
"""Test complete signup endpoint at /api/v1/public/signup/complete.""" """Test complete signup endpoint at /api/v1/platform/signup/complete."""
def test_complete_signup_success(self, client, signup_session, mock_stripe_service, db): def test_complete_signup_success(self, client, signup_session, mock_stripe_service, db):
"""Test completing signup after payment setup.""" """Test completing signup after payment setup."""
# Create account # Create account
client.post( client.post(
"/api/v1/public/signup/create-account", "/api/v1/platform/signup/create-account",
json={ json={
"session_id": signup_session, "session_id": signup_session,
"email": "complete@example.com", "email": "complete@example.com",
@@ -442,13 +442,13 @@ class TestCompleteSignupAPI:
# Setup payment # Setup payment
client.post( client.post(
"/api/v1/public/signup/setup-payment", "/api/v1/platform/signup/setup-payment",
json={"session_id": signup_session}, json={"session_id": signup_session},
) )
# Complete signup # Complete signup
response = client.post( response = client.post(
"/api/v1/public/signup/complete", "/api/v1/platform/signup/complete",
json={ json={
"session_id": signup_session, "session_id": signup_session,
"setup_intent_id": "seti_test_123", "setup_intent_id": "seti_test_123",
@@ -469,7 +469,7 @@ class TestCompleteSignupAPI:
"""Test that completing signup returns a valid JWT access token for auto-login.""" """Test that completing signup returns a valid JWT access token for auto-login."""
# Create account # Create account
client.post( client.post(
"/api/v1/public/signup/create-account", "/api/v1/platform/signup/create-account",
json={ json={
"session_id": signup_session, "session_id": signup_session,
"email": "token_test@example.com", "email": "token_test@example.com",
@@ -482,13 +482,13 @@ class TestCompleteSignupAPI:
# Setup payment # Setup payment
client.post( client.post(
"/api/v1/public/signup/setup-payment", "/api/v1/platform/signup/setup-payment",
json={"session_id": signup_session}, json={"session_id": signup_session},
) )
# Complete signup # Complete signup
response = client.post( response = client.post(
"/api/v1/public/signup/complete", "/api/v1/platform/signup/complete",
json={ json={
"session_id": signup_session, "session_id": signup_session,
"setup_intent_id": "seti_test_123", "setup_intent_id": "seti_test_123",
@@ -509,7 +509,7 @@ class TestCompleteSignupAPI:
"""Test that the returned access token can be used to authenticate API calls.""" """Test that the returned access token can be used to authenticate API calls."""
# Create account # Create account
client.post( client.post(
"/api/v1/public/signup/create-account", "/api/v1/platform/signup/create-account",
json={ json={
"session_id": signup_session, "session_id": signup_session,
"email": "auth_test@example.com", "email": "auth_test@example.com",
@@ -522,13 +522,13 @@ class TestCompleteSignupAPI:
# Setup payment # Setup payment
client.post( client.post(
"/api/v1/public/signup/setup-payment", "/api/v1/platform/signup/setup-payment",
json={"session_id": signup_session}, json={"session_id": signup_session},
) )
# Complete signup # Complete signup
complete_response = client.post( complete_response = client.post(
"/api/v1/public/signup/complete", "/api/v1/platform/signup/complete",
json={ json={
"session_id": signup_session, "session_id": signup_session,
"setup_intent_id": "seti_test_123", "setup_intent_id": "seti_test_123",
@@ -553,7 +553,7 @@ class TestCompleteSignupAPI:
"""Test that completing signup sets the vendor_token HTTP-only cookie.""" """Test that completing signup sets the vendor_token HTTP-only cookie."""
# Create account # Create account
client.post( client.post(
"/api/v1/public/signup/create-account", "/api/v1/platform/signup/create-account",
json={ json={
"session_id": signup_session, "session_id": signup_session,
"email": "cookie_test@example.com", "email": "cookie_test@example.com",
@@ -566,13 +566,13 @@ class TestCompleteSignupAPI:
# Setup payment # Setup payment
client.post( client.post(
"/api/v1/public/signup/setup-payment", "/api/v1/platform/signup/setup-payment",
json={"session_id": signup_session}, json={"session_id": signup_session},
) )
# Complete signup # Complete signup
response = client.post( response = client.post(
"/api/v1/public/signup/complete", "/api/v1/platform/signup/complete",
json={ json={
"session_id": signup_session, "session_id": signup_session,
"setup_intent_id": "seti_test_123", "setup_intent_id": "seti_test_123",
@@ -588,7 +588,7 @@ class TestCompleteSignupAPI:
def test_complete_signup_invalid_session(self, client, mock_stripe_service): def test_complete_signup_invalid_session(self, client, mock_stripe_service):
"""Test completing signup with invalid session.""" """Test completing signup with invalid session."""
response = client.post( response = client.post(
"/api/v1/public/signup/complete", "/api/v1/platform/signup/complete",
json={ json={
"session_id": "invalid_session", "session_id": "invalid_session",
"setup_intent_id": "seti_test_123", "setup_intent_id": "seti_test_123",
@@ -603,7 +603,7 @@ class TestCompleteSignupAPI:
"""Test completing signup when payment setup failed.""" """Test completing signup when payment setup failed."""
# Create account # Create account
client.post( client.post(
"/api/v1/public/signup/create-account", "/api/v1/platform/signup/create-account",
json={ json={
"session_id": signup_session, "session_id": signup_session,
"email": "fail@example.com", "email": "fail@example.com",
@@ -616,7 +616,7 @@ class TestCompleteSignupAPI:
# Setup payment # Setup payment
client.post( client.post(
"/api/v1/public/signup/setup-payment", "/api/v1/platform/signup/setup-payment",
json={"session_id": signup_session}, json={"session_id": signup_session},
) )
@@ -629,7 +629,7 @@ class TestCompleteSignupAPI:
# Complete signup # Complete signup
response = client.post( response = client.post(
"/api/v1/public/signup/complete", "/api/v1/platform/signup/complete",
json={ json={
"session_id": signup_session, "session_id": signup_session,
"setup_intent_id": "seti_failed", "setup_intent_id": "seti_failed",
@@ -645,11 +645,11 @@ class TestCompleteSignupAPI:
@pytest.mark.api @pytest.mark.api
@pytest.mark.platform @pytest.mark.platform
class TestGetSignupSessionAPI: class TestGetSignupSessionAPI:
"""Test get signup session endpoint at /api/v1/public/signup/session/{session_id}.""" """Test get signup session endpoint at /api/v1/platform/signup/session/{session_id}."""
def test_get_session_after_start(self, client, signup_session): def test_get_session_after_start(self, client, signup_session):
"""Test getting session after starting signup.""" """Test getting session after starting signup."""
response = client.get(f"/api/v1/public/signup/session/{signup_session}") response = client.get(f"/api/v1/platform/signup/session/{signup_session}")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -661,14 +661,14 @@ class TestGetSignupSessionAPI:
def test_get_session_after_claim(self, client, signup_session): def test_get_session_after_claim(self, client, signup_session):
"""Test getting session after claiming vendor.""" """Test getting session after claiming vendor."""
client.post( client.post(
"/api/v1/public/signup/claim-vendor", "/api/v1/platform/signup/claim-vendor",
json={ json={
"session_id": signup_session, "session_id": signup_session,
"letzshop_slug": "my-session-shop", "letzshop_slug": "my-session-shop",
}, },
) )
response = client.get(f"/api/v1/public/signup/session/{signup_session}") response = client.get(f"/api/v1/platform/signup/session/{signup_session}")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -677,7 +677,7 @@ class TestGetSignupSessionAPI:
def test_get_session_invalid_id(self, client): def test_get_session_invalid_id(self, client):
"""Test getting non-existent session.""" """Test getting non-existent session."""
response = client.get("/api/v1/public/signup/session/invalid_id") response = client.get("/api/v1/platform/signup/session/invalid_id")
assert response.status_code == 404 assert response.status_code == 404
@@ -692,7 +692,7 @@ class TestSignupFullFlow:
"""Test the complete signup flow from start to finish.""" """Test the complete signup flow from start to finish."""
# Step 1: Start signup # Step 1: Start signup
start_response = client.post( start_response = client.post(
"/api/v1/public/signup/start", "/api/v1/platform/signup/start",
json={"tier_code": TierCode.BUSINESS.value, "is_annual": True}, json={"tier_code": TierCode.BUSINESS.value, "is_annual": True},
) )
assert start_response.status_code == 200 assert start_response.status_code == 200
@@ -700,7 +700,7 @@ class TestSignupFullFlow:
# Step 2: Claim Letzshop vendor (optional) # Step 2: Claim Letzshop vendor (optional)
claim_response = client.post( claim_response = client.post(
"/api/v1/public/signup/claim-vendor", "/api/v1/platform/signup/claim-vendor",
json={ json={
"session_id": session_id, "session_id": session_id,
"letzshop_slug": "full-flow-shop", "letzshop_slug": "full-flow-shop",
@@ -710,7 +710,7 @@ class TestSignupFullFlow:
# Step 3: Create account # Step 3: Create account
account_response = client.post( account_response = client.post(
"/api/v1/public/signup/create-account", "/api/v1/platform/signup/create-account",
json={ json={
"session_id": session_id, "session_id": session_id,
"email": "fullflow@example.com", "email": "fullflow@example.com",
@@ -726,7 +726,7 @@ class TestSignupFullFlow:
# Step 4: Setup payment # Step 4: Setup payment
payment_response = client.post( payment_response = client.post(
"/api/v1/public/signup/setup-payment", "/api/v1/platform/signup/setup-payment",
json={"session_id": session_id}, json={"session_id": session_id},
) )
assert payment_response.status_code == 200 assert payment_response.status_code == 200
@@ -734,7 +734,7 @@ class TestSignupFullFlow:
# Step 5: Complete signup # Step 5: Complete signup
complete_response = client.post( complete_response = client.post(
"/api/v1/public/signup/complete", "/api/v1/platform/signup/complete",
json={ json={
"session_id": session_id, "session_id": session_id,
"setup_intent_id": "seti_test_123", "setup_intent_id": "seti_test_123",
@@ -753,14 +753,14 @@ class TestSignupFullFlow:
"""Test signup flow skipping Letzshop claim step.""" """Test signup flow skipping Letzshop claim step."""
# Step 1: Start signup # Step 1: Start signup
start_response = client.post( start_response = client.post(
"/api/v1/public/signup/start", "/api/v1/platform/signup/start",
json={"tier_code": TierCode.ESSENTIAL.value, "is_annual": False}, json={"tier_code": TierCode.ESSENTIAL.value, "is_annual": False},
) )
session_id = start_response.json()["session_id"] session_id = start_response.json()["session_id"]
# Skip Step 2, go directly to Step 3 # Skip Step 2, go directly to Step 3
account_response = client.post( account_response = client.post(
"/api/v1/public/signup/create-account", "/api/v1/platform/signup/create-account",
json={ json={
"session_id": session_id, "session_id": session_id,
"email": "noletzshop@example.com", "email": "noletzshop@example.com",
@@ -775,12 +775,12 @@ class TestSignupFullFlow:
# Step 4 & 5: Payment and complete # Step 4 & 5: Payment and complete
client.post( client.post(
"/api/v1/public/signup/setup-payment", "/api/v1/platform/signup/setup-payment",
json={"session_id": session_id}, json={"session_id": session_id},
) )
complete_response = client.post( complete_response = client.post(
"/api/v1/public/signup/complete", "/api/v1/platform/signup/complete",
json={ json={
"session_id": session_id, "session_id": session_id,
"setup_intent_id": "seti_test_123", "setup_intent_id": "seti_test_123",