first commit

This commit is contained in:
Chris
2026-03-23 20:23:58 +08:00
commit 74d612aca1
3193 changed files with 692056 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""API routers."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
"""Admin API routers."""

View File

@@ -0,0 +1,76 @@
from fastapi import APIRouter, Depends, HTTPException, status
from app.api.dependencies.auth import get_access_token
from app.api.dependencies.permissions import require_permission
from app.application.admin.activity import ActivityService
from app.application.admin.experiments import ExperimentService
from app.schemas.auth import AuthenticatedUser
from app.schemas.admin import ActivityLogListResponse, ExperimentCreate, ExperimentListResponse, ExperimentRead, ExperimentUpdate
router = APIRouter()
service = ExperimentService()
activity_service = ActivityService()
@router.get("", response_model=ExperimentListResponse)
async def list_experiments(
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_experiments")),
) -> ExperimentListResponse:
# Admin list should always come from the application/service layer,
# not from routes talking to Directus directly.
items = await service.list_experiments(access_token=access_token)
return ExperimentListResponse(items=items)
@router.get("/{experiment_id}", response_model=ExperimentRead)
async def get_experiment(
experiment_id: str,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_experiments")),
) -> ExperimentRead:
item = await service.get_experiment(experiment_id, access_token=access_token)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Experiment '{experiment_id}' not found.",
)
return item
@router.post("", response_model=ExperimentRead, status_code=status.HTTP_201_CREATED)
async def create_experiment(
payload: ExperimentCreate,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_experiments")),
) -> ExperimentRead:
return await service.create_experiment(payload, access_token=access_token)
@router.patch("/{experiment_id}", response_model=ExperimentRead)
async def update_experiment(
experiment_id: str,
payload: ExperimentUpdate,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_experiments")),
) -> ExperimentRead:
item = await service.update_experiment(experiment_id, payload, access_token=access_token)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Experiment '{experiment_id}' not found.",
)
return item
@router.get("/{experiment_id}/activity", response_model=ActivityLogListResponse)
async def list_experiment_activity(
experiment_id: str,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_experiments")),
) -> ActivityLogListResponse:
items = await activity_service.list_for_experiment(
experiment_id=experiment_id,
access_token=access_token,
)
return ActivityLogListResponse(items=items)

View File

@@ -0,0 +1,35 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.api.dependencies.auth import get_access_token
from app.api.dependencies.permissions import require_permission
from app.application.admin.goals import GoalService
from app.schemas.auth import AuthenticatedUser
from app.schemas.admin import GoalListResponse, GoalRead
router = APIRouter()
service = GoalService()
@router.get("", response_model=GoalListResponse)
async def list_goals(
site_id: str | None = Query(default=None),
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_goals")),
) -> GoalListResponse:
items = await service.list_goals(site_id=site_id, access_token=access_token)
return GoalListResponse(items=items)
@router.get("/{goal_id}", response_model=GoalRead)
async def get_goal(
goal_id: str,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_goals")),
) -> GoalRead:
item = await service.get_goal(goal_id, access_token=access_token)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Goal '{goal_id}' not found.",
)
return item

View File

@@ -0,0 +1,32 @@
from fastapi import APIRouter, Depends, HTTPException, status
from app.api.dependencies.permissions import require_permission
from app.application.admin.marketing_cards import MarketingCardService
from app.schemas.auth import AuthenticatedUser
from app.schemas.marketing_card import MarketingCardListResponse, MarketingCardRead
router = APIRouter()
service = MarketingCardService()
@router.get("", response_model=MarketingCardListResponse)
async def list_marketing_cards(
_: AuthenticatedUser = Depends(require_permission("can_manage_experiments")),
) -> MarketingCardListResponse:
items = await service.list_marketing_cards()
return MarketingCardListResponse(items=items)
@router.get("/{card_id}", response_model=MarketingCardRead)
async def get_marketing_card(
card_id: str,
_: AuthenticatedUser = Depends(require_permission("can_manage_experiments")),
) -> MarketingCardRead:
item = await service.get_marketing_card(card_id)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Marketing card '{card_id}' not found.",
)
return item

View File

@@ -0,0 +1,77 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.api.dependencies.auth import get_access_token
from app.api.dependencies.permissions import require_permission
from app.application.admin.releases import ReleaseService
from app.schemas.auth import AuthenticatedUser
from app.schemas.admin import BuildReleaseRequest, ReleaseLifecycleResponse, ReleaseListResponse, ReleaseRead
router = APIRouter()
service = ReleaseService()
@router.get("", response_model=ReleaseListResponse)
async def list_releases(
experiment_id: str | None = Query(default=None),
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_releases")),
) -> ReleaseListResponse:
items = await service.list_releases(experiment_id=experiment_id, access_token=access_token)
return ReleaseListResponse(items=items)
@router.get("/{release_id}", response_model=ReleaseRead)
async def get_release(
release_id: str,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_releases")),
) -> ReleaseRead:
item = await service.get_release(release_id, access_token=access_token)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Release '{release_id}' not found.")
return item
@router.post("/build", response_model=ReleaseRead, status_code=status.HTTP_201_CREATED)
async def build_release(
payload: BuildReleaseRequest,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_releases")),
) -> ReleaseRead:
return await service.build_release(payload, access_token=access_token)
@router.post("/{release_id}/publish", response_model=ReleaseLifecycleResponse)
async def publish_release(
release_id: str,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_releases")),
) -> ReleaseLifecycleResponse:
item = await service.publish_release(release_id, access_token=access_token)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Release '{release_id}' not found.")
return item
@router.post("/{release_id}/rollback", response_model=ReleaseLifecycleResponse)
async def rollback_release(
release_id: str,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_releases")),
) -> ReleaseLifecycleResponse:
item = await service.rollback_release(release_id, access_token=access_token)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Release '{release_id}' not found.")
return item
@router.post("/{release_id}/archive", response_model=ReleaseLifecycleResponse)
async def archive_release(
release_id: str,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_releases")),
) -> ReleaseLifecycleResponse:
item = await service.archive_release(release_id, access_token=access_token)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Release '{release_id}' not found.")
return item

View File

@@ -0,0 +1,18 @@
from fastapi import APIRouter
from app.api.admin import (
experiments,
goals,
releases,
sdk_configs,
sites,
variants,
)
router = APIRouter()
router.include_router(sites.router, prefix="/sites")
router.include_router(experiments.router, prefix="/experiments")
router.include_router(variants.router, prefix="/variants")
router.include_router(releases.router, prefix="/releases")
router.include_router(goals.router, prefix="/goals")
router.include_router(sdk_configs.router, prefix="/sdk-configs")

View File

@@ -0,0 +1,34 @@
from fastapi import APIRouter, Depends, HTTPException, status
from app.api.dependencies.auth import get_access_token
from app.api.dependencies.permissions import require_permission
from app.application.admin.sdk_configs import SdkConfigService
from app.schemas.auth import AuthenticatedUser
from app.schemas.admin import SdkConfigListResponse, SdkConfigRead
router = APIRouter()
service = SdkConfigService()
@router.get("", response_model=SdkConfigListResponse)
async def list_sdk_configs(
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_sdk_configs")),
) -> SdkConfigListResponse:
items = await service.list_sdk_configs(access_token=access_token)
return SdkConfigListResponse(items=items)
@router.get("/{sdk_config_id}", response_model=SdkConfigRead)
async def get_sdk_config(
sdk_config_id: str,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_sdk_configs")),
) -> SdkConfigRead:
item = await service.get_sdk_config(sdk_config_id, access_token=access_token)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"SDK config '{sdk_config_id}' not found.",
)
return item

View File

@@ -0,0 +1,34 @@
from fastapi import APIRouter, Depends, HTTPException, status
from app.api.dependencies.auth import get_access_token
from app.api.dependencies.permissions import require_permission
from app.application.admin.sites import SiteService
from app.schemas.auth import AuthenticatedUser
from app.schemas.admin import SiteListResponse, SiteRead
router = APIRouter()
service = SiteService()
@router.get("", response_model=SiteListResponse)
async def list_sites(
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_sites")),
) -> SiteListResponse:
items = await service.list_sites(access_token=access_token)
return SiteListResponse(items=items)
@router.get("/{site_id}", response_model=SiteRead)
async def get_site(
site_id: str,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_sites")),
) -> SiteRead:
item = await service.get_site(site_id, access_token=access_token)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Site '{site_id}' not found.",
)
return item

View File

@@ -0,0 +1,63 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.api.dependencies.auth import get_access_token
from app.api.dependencies.permissions import require_permission
from app.application.admin.variants import VariantService
from app.schemas.auth import AuthenticatedUser
from app.schemas.admin import VariantCreate, VariantListResponse, VariantRead, VariantUpdate
router = APIRouter()
service = VariantService()
@router.get("", response_model=VariantListResponse)
async def list_variants(
experiment_id: str | None = Query(default=None),
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_variants")),
) -> VariantListResponse:
items = await service.list_variants(
experiment_id=experiment_id,
access_token=access_token,
)
return VariantListResponse(items=items)
@router.get("/{variant_id}", response_model=VariantRead)
async def get_variant(
variant_id: str,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_variants")),
) -> VariantRead:
item = await service.get_variant(variant_id, access_token=access_token)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Variant '{variant_id}' not found.",
)
return item
@router.post("", response_model=VariantRead, status_code=status.HTTP_201_CREATED)
async def create_variant(
payload: VariantCreate,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_variants")),
) -> VariantRead:
return await service.create_variant(payload, access_token=access_token)
@router.patch("/{variant_id}", response_model=VariantRead)
async def update_variant(
variant_id: str,
payload: VariantUpdate,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_variants")),
) -> VariantRead:
item = await service.update_variant(variant_id, payload, access_token=access_token)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Variant '{variant_id}' not found.",
)
return item

16
backend/app/api/auth.py Normal file
View File

@@ -0,0 +1,16 @@
from fastapi import APIRouter, Depends
from app.api.dependencies.auth import get_current_user
from app.schemas.auth import AuthMeResponse, AuthenticatedUser
router = APIRouter(prefix="/auth", tags=["auth"])
@router.get("/me", response_model=AuthMeResponse)
async def get_me(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> AuthMeResponse:
"""Return normalized current-user context for frontend session bootstrapping."""
return AuthMeResponse(user=current_user)

View File

@@ -0,0 +1,2 @@
"""Reusable FastAPI dependencies."""

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.application.auth.context import AuthContextService
from app.schemas.auth import AuthenticatedUser
bearer_scheme = HTTPBearer(auto_error=False)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
) -> AuthenticatedUser:
"""Resolve current user from a Directus-issued bearer token.
This keeps auth enforcement centralized and makes later permission mapping
easier to add without rewriting every route.
"""
if not credentials or not credentials.credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authorization header is required.",
)
service = AuthContextService()
return await service.get_authenticated_user(credentials.credentials)
async def get_access_token(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
) -> str:
"""Return the raw Directus bearer token for repository passthrough reads."""
if not credentials or not credentials.credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authorization header is required.",
)
return credentials.credentials

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
from collections.abc import Callable
from fastapi import Depends, HTTPException, status
from app.api.dependencies.auth import get_current_user
from app.schemas.auth import AuthenticatedUser
def require_permission(permission_name: str) -> Callable[..., AuthenticatedUser]:
"""Create a dependency that enforces a translated permission flag.
The flag names intentionally match `PermissionContextRead` fields so
reviewers can trace permission checks end to end without indirection.
"""
async def dependency(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> AuthenticatedUser:
if not hasattr(current_user.permissions, permission_name):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Unknown permission flag '{permission_name}'.",
)
if not getattr(current_user.permissions, permission_name):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing permission '{permission_name}'.",
)
return current_user
return dependency

View File

@@ -0,0 +1 @@
"""Editor API routers."""

View File

@@ -0,0 +1,90 @@
from fastapi import APIRouter, Depends, HTTPException, status
from app.api.dependencies.auth import get_access_token
from app.api.dependencies.permissions import require_permission
from app.application.editor.service import EditorService
from app.schemas.auth import AuthenticatedUser
from app.schemas.editor import (
BuildPreviewRequest,
BuildPreviewResponse,
EditorChangeListResponse,
EditorSessionCreateRequest,
EditorSessionRead,
EditorSessionUpdateRequest,
SaveVariantChangesRequest,
)
router = APIRouter()
service = EditorService()
@router.post("/sessions", response_model=EditorSessionRead)
async def create_editor_session(
request: EditorSessionCreateRequest,
current_user: AuthenticatedUser = Depends(require_permission("can_use_editor")),
) -> EditorSessionRead:
return await service.create_session(request, current_user)
@router.get("/sessions/{session_id}", response_model=EditorSessionRead)
async def get_editor_session(
session_id: str,
_: AuthenticatedUser = Depends(require_permission("can_use_editor")),
) -> EditorSessionRead:
item = await service.get_session(session_id)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Editor session '{session_id}' not found.",
)
return item
@router.patch("/sessions/{session_id}", response_model=EditorSessionRead)
async def update_editor_session(
session_id: str,
request: EditorSessionUpdateRequest,
_: AuthenticatedUser = Depends(require_permission("can_use_editor")),
) -> EditorSessionRead:
item = await service.update_session(session_id, request)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Editor session '{session_id}' not found.",
)
return item
@router.delete("/sessions/{session_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_editor_session(
session_id: str,
_: AuthenticatedUser = Depends(require_permission("can_use_editor")),
) -> None:
await service.delete_session(session_id)
@router.get("/variants/{variant_id}/changes", response_model=EditorChangeListResponse)
async def list_variant_changes(
variant_id: str,
_: AuthenticatedUser = Depends(require_permission("can_use_editor")),
access_token: str = Depends(get_access_token),
) -> EditorChangeListResponse:
return await service.list_changes_with_access_token(variant_id, access_token)
@router.put("/variants/{variant_id}/changes", response_model=EditorChangeListResponse)
async def save_variant_changes(
variant_id: str,
request: SaveVariantChangesRequest,
_: AuthenticatedUser = Depends(require_permission("can_use_editor")),
access_token: str = Depends(get_access_token),
) -> EditorChangeListResponse:
return await service.save_changes_with_access_token(variant_id, request, access_token)
@router.post("/previews/build", response_model=BuildPreviewResponse)
async def build_preview(
request: BuildPreviewRequest,
_: AuthenticatedUser = Depends(require_permission("can_use_editor")),
) -> BuildPreviewResponse:
return await service.build_preview(request)

12
backend/app/api/router.py Normal file
View File

@@ -0,0 +1,12 @@
from fastapi import APIRouter
from app.api.auth import router as auth_router
from app.api.admin.router import router as admin_router
from app.api.editor.router import router as editor_router
from app.api.runtime.router import router as runtime_router
api_router = APIRouter()
api_router.include_router(auth_router)
api_router.include_router(admin_router, prefix="/admin", tags=["admin"])
api_router.include_router(editor_router, prefix="/editor", tags=["editor"])
api_router.include_router(runtime_router, prefix="/runtime", tags=["runtime"])

View File

@@ -0,0 +1 @@
"""Runtime API routers."""

View File

@@ -0,0 +1,49 @@
from fastapi import APIRouter, HTTPException, status
from app.application.runtime.assignment import RuntimeService
from app.schemas.runtime import (
RuntimeAssignRequest,
RuntimeAssignResponse,
RuntimeBootstrapRequest,
RuntimeBootstrapResponse,
RuntimeEventRequest,
RuntimeEventResponse,
RuntimePayloadRequest,
RuntimePayloadResponse,
)
router = APIRouter()
service = RuntimeService()
@router.post("/bootstrap", response_model=RuntimeBootstrapResponse)
async def runtime_bootstrap(
request: RuntimeBootstrapRequest,
) -> RuntimeBootstrapResponse:
return await service.bootstrap(request)
@router.post("/assign", response_model=RuntimeAssignResponse)
async def runtime_assign(request: RuntimeAssignRequest) -> RuntimeAssignResponse:
item = await service.assign(request)
if not item:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="No assignable variants were provided.",
)
return item
@router.post("/payload", response_model=RuntimePayloadResponse)
async def runtime_payload(request: RuntimePayloadRequest) -> RuntimePayloadResponse:
return await service.payload(request)
@router.post("/events/impression", response_model=RuntimeEventResponse)
async def runtime_impression(request: RuntimeEventRequest) -> RuntimeEventResponse:
return await service.ingest_event(request)
@router.post("/events/conversion", response_model=RuntimeEventResponse)
async def runtime_conversion(request: RuntimeEventRequest) -> RuntimeEventResponse:
return await service.ingest_event(request)