Spaces:
Running
Running
| from __future__ import annotations | |
| from datetime import datetime | |
| from typing import Annotated | |
| from fastapi import APIRouter, Header, HTTPException, Query, Request, Response, status | |
| from app.social.schemas.accounts import ( | |
| SocialAccountConnectRequest, | |
| SocialAccountSelectionRequest, | |
| SocialAccountView, | |
| SocialConnectResponse, | |
| SocialProviderView, | |
| SocialPublishOptionsView, | |
| ) | |
| from app.social.schemas.assets import ( | |
| SocialMediaAssetRegister, | |
| SocialMediaAssetView, | |
| ) | |
| from app.social.schemas.jobs import SocialJobView | |
| from app.social.schemas.operations import ( | |
| PublishingBatchView, | |
| PublishingBulkRequest, | |
| PublishingCalendarView, | |
| PublishingContextView, | |
| PublishingQueueView, | |
| ) | |
| from app.social.schemas.posts import ( | |
| SocialPostCreate, | |
| SocialPostDuplicateRequest, | |
| SocialPostPatch, | |
| SocialPostValidation, | |
| SocialPostView, | |
| ) | |
| from app.social.schemas.scheduling import ( | |
| SocialRescheduleRequest, | |
| SocialScheduleCreate, | |
| SocialScheduleView, | |
| ) | |
| router = APIRouter(prefix="/v1/social", tags=["social automation"]) | |
| def _social(request: Request): | |
| service = request.app.state.container.social | |
| service.ensure_ready() | |
| return service | |
| def _identity(request: Request) -> tuple[str, str]: | |
| context = request.state.auth | |
| if not context.workspace_id or not context.user_id: | |
| # Tenant resolution is performed by APIKeyService after credential | |
| # verification. Never fall back to treating an API-key ID as a tenant. | |
| raise HTTPException(status_code=403, detail="No active workspace membership.") | |
| return context.workspace_id, context.user_id | |
| async def _audit( | |
| request: Request, | |
| event_type: str, | |
| *, | |
| provider: str | None = None, | |
| account_id: str | None = None, | |
| post_id: str | None = None, | |
| job_id: str | None = None, | |
| ) -> None: | |
| workspace_id, _ = _identity(request) | |
| await request.app.state.container.social.audit.record( | |
| workspace_id=workspace_id, | |
| event_type=event_type, | |
| api_key_id=request.state.auth.api_key_id, | |
| request_id=request.state.request_id, | |
| provider=provider, | |
| social_account_id=account_id, | |
| social_post_id=post_id, | |
| social_job_id=job_id, | |
| ) | |
| async def list_providers(request: Request) -> list[SocialProviderView]: | |
| return request.app.state.container.social.accounts.list_providers() | |
| async def provider_capabilities(request: Request, provider: str) -> SocialProviderView: | |
| return request.app.state.container.social.accounts.get_provider(provider) | |
| async def list_social_media_assets( | |
| request: Request, | |
| offset: int = Query(default=0, ge=0), | |
| limit: int = Query(default=100, ge=1, le=500), | |
| ) -> list[SocialMediaAssetView]: | |
| workspace_id, _ = _identity(request) | |
| return await _social(request).media_assets.list(workspace_id, offset=offset, limit=limit) | |
| async def register_social_media_asset( | |
| request: Request, payload: SocialMediaAssetRegister | |
| ) -> SocialMediaAssetView: | |
| workspace_id, _ = _identity(request) | |
| return await _social(request).media_assets.register(workspace_id, payload) | |
| async def list_accounts( | |
| request: Request, | |
| offset: int = Query(default=0, ge=0), | |
| limit: int = Query(default=100, ge=1, le=500), | |
| ) -> list[SocialAccountView]: | |
| workspace_id, _ = _identity(request) | |
| return await _social(request).accounts.list(workspace_id, offset=offset, limit=limit) | |
| async def select_discovered_accounts( | |
| request: Request, payload: SocialAccountSelectionRequest | |
| ) -> list[SocialAccountView]: | |
| workspace_id, _ = _identity(request) | |
| selected = await _social(request).accounts.select_discovered(workspace_id, payload.account_ids) | |
| for account in selected: | |
| await _audit( | |
| request, | |
| "SOCIAL_ACCOUNT_CONNECTED", | |
| provider=account.provider.value, | |
| account_id=account.id, | |
| ) | |
| return selected | |
| async def get_account(request: Request, account_id: str) -> SocialAccountView: | |
| workspace_id, _ = _identity(request) | |
| return await _social(request).accounts.get(workspace_id, account_id) | |
| async def get_account_publish_options( | |
| request: Request, account_id: str | |
| ) -> SocialPublishOptionsView: | |
| workspace_id, _ = _identity(request) | |
| return await _social(request).publishing.publish_options(workspace_id, account_id) | |
| async def connect_account( | |
| request: Request, provider: str, payload: SocialAccountConnectRequest | |
| ) -> SocialConnectResponse: | |
| workspace_id, user_id = _identity(request) | |
| result = await _social(request).oauth.connect( | |
| provider=provider, | |
| workspace_id=workspace_id, | |
| user_id=user_id, | |
| payload=payload, | |
| ) | |
| await _audit(request, "SOCIAL_ACCOUNT_CONNECTION_STARTED", provider=provider) | |
| return result | |
| async def oauth_callback( | |
| request: Request, | |
| provider: str, | |
| state: Annotated[str, Query(min_length=32, max_length=255, pattern=r"^[A-Za-z0-9_-]+$")], | |
| code: Annotated[str | None, Query(min_length=1, max_length=4096)] = None, | |
| error: Annotated[str | None, Query(max_length=128)] = None, | |
| ) -> SocialAccountView: | |
| # This provider-facing route is authenticated by a short-lived, single-use | |
| # state record. Tenant/user identifiers are never accepted from the query. | |
| social = request.app.state.container.social | |
| social.ensure_ready() | |
| if error or not code: | |
| await social.oauth.callback_denied(provider=provider, state=state) | |
| raise AssertionError("OAuth callback denial should raise a social error") | |
| return await social.oauth.callback(provider=provider, state=state, code=code) | |
| async def refresh_account(request: Request, account_id: str) -> SocialAccountView: | |
| workspace_id, _ = _identity(request) | |
| result = await _social(request).oauth.refresh(workspace_id=workspace_id, account_id=account_id) | |
| await _audit(request, "SOCIAL_ACCOUNT_REAUTHORIZED", account_id=account_id) | |
| return result | |
| async def disconnect_account(request: Request, account_id: str) -> Response: | |
| workspace_id, _ = _identity(request) | |
| account = await _social(request).accounts.get(workspace_id, account_id) | |
| await request.app.state.container.social.accounts.disconnect(workspace_id, account_id) | |
| await _audit( | |
| request, | |
| "SOCIAL_ACCOUNT_DISCONNECTED", | |
| provider=account.provider.value, | |
| account_id=account_id, | |
| ) | |
| return Response(status_code=status.HTTP_204_NO_CONTENT) | |
| async def create_post( | |
| request: Request, | |
| payload: SocialPostCreate, | |
| idempotency_key: str | None = Header( | |
| default=None, alias="Idempotency-Key", min_length=8, max_length=255 | |
| ), | |
| ) -> SocialPostView: | |
| workspace_id, user_id = _identity(request) | |
| result = await _social(request).publishing.create( | |
| workspace_id=workspace_id, | |
| user_id=user_id, | |
| payload=payload, | |
| idempotency_key=idempotency_key, | |
| ) | |
| await _audit(request, "SOCIAL_POST_CREATED", post_id=result.id) | |
| if result.publish_mode.value == "draft": | |
| await _audit(request, "publishing.draft_created", post_id=result.id) | |
| return result | |
| async def list_posts( | |
| request: Request, | |
| offset: int = Query(default=0, ge=0), | |
| limit: int = Query(default=100, ge=1, le=500), | |
| post_status: str | None = Query(default=None, alias="status", max_length=32), | |
| project_id: str | None = Query(default=None, max_length=36), | |
| search: str | None = Query(default=None, max_length=200), | |
| ) -> list[SocialPostView]: | |
| workspace_id, _ = _identity(request) | |
| return await _social(request).publishing.list( | |
| workspace_id, | |
| offset=offset, | |
| limit=limit, | |
| status=post_status, | |
| project_id=project_id, | |
| search=search, | |
| ) | |
| async def get_post(request: Request, post_id: str) -> SocialPostView: | |
| workspace_id, _ = _identity(request) | |
| return await _social(request).publishing.get(workspace_id, post_id) | |
| async def update_post(request: Request, post_id: str, payload: SocialPostPatch) -> SocialPostView: | |
| workspace_id, _ = _identity(request) | |
| result = await _social(request).operations.update_draft(workspace_id, post_id, payload) | |
| await _audit(request, "publishing.draft_updated", post_id=post_id) | |
| return result | |
| async def list_drafts( | |
| request: Request, | |
| offset: int = Query(default=0, ge=0), | |
| limit: int = Query(default=100, ge=1, le=500), | |
| search: str | None = Query(default=None, max_length=200), | |
| ) -> list[SocialPostView]: | |
| workspace_id, _ = _identity(request) | |
| posts = await _social(request).publishing.list( | |
| workspace_id, | |
| offset=offset, | |
| limit=limit, | |
| search=search, | |
| ) | |
| return [ | |
| post | |
| for post in posts | |
| if post.status.value in {"draft", "ready", "failed"} and post.publish_mode.value == "draft" | |
| ] | |
| async def update_draft(request: Request, post_id: str, payload: SocialPostPatch) -> SocialPostView: | |
| return await update_post(request, post_id, payload) | |
| async def delete_draft(request: Request, post_id: str) -> Response: | |
| workspace_id, _ = _identity(request) | |
| await _social(request).operations.delete_draft(workspace_id, post_id) | |
| await _audit(request, "publishing.draft_deleted", post_id=post_id) | |
| return Response(status_code=status.HTTP_204_NO_CONTENT) | |
| async def duplicate_post( | |
| request: Request, | |
| post_id: str, | |
| payload: SocialPostDuplicateRequest, | |
| idempotency_key: str = Header(alias="Idempotency-Key", min_length=8, max_length=255), | |
| ) -> SocialPostView: | |
| workspace_id, user_id = _identity(request) | |
| result = await _social(request).operations.duplicate( | |
| workspace_id, | |
| user_id, | |
| post_id, | |
| payload, | |
| idempotency_key=idempotency_key, | |
| ) | |
| await _audit(request, "publishing.duplicated", post_id=result.id) | |
| return result | |
| async def validate_post(request: Request, post_id: str) -> SocialPostValidation: | |
| workspace_id, _ = _identity(request) | |
| result = await _social(request).publishing.validate_post_targets(workspace_id, post_id) | |
| await _audit(request, "SOCIAL_POST_VALIDATED", post_id=post_id) | |
| await _audit(request, "publishing.validated", post_id=post_id) | |
| return result | |
| async def delete_post(request: Request, post_id: str) -> Response: | |
| workspace_id, _ = _identity(request) | |
| await _social(request).publishing.delete(workspace_id, post_id) | |
| await _audit(request, "SOCIAL_POST_DELETED", post_id=post_id) | |
| return Response(status_code=status.HTTP_204_NO_CONTENT) | |
| async def publish_post( | |
| request: Request, | |
| post_id: str, | |
| idempotency_key: str = Header(alias="Idempotency-Key", min_length=8, max_length=255), | |
| ) -> list[SocialJobView]: | |
| workspace_id, _ = _identity(request) | |
| jobs = await _social(request).publishing.queue( | |
| workspace_id, post_id, idempotency_key=idempotency_key | |
| ) | |
| await _audit(request, "SOCIAL_POST_PUBLISH_STARTED", post_id=post_id) | |
| return jobs | |
| async def schedule_post( | |
| request: Request, post_id: str, payload: SocialScheduleCreate | |
| ) -> SocialScheduleView: | |
| workspace_id, _ = _identity(request) | |
| validation = await _social(request).publishing.validate_post_targets(workspace_id, post_id) | |
| if not validation.valid: | |
| raise HTTPException( | |
| status_code=422, | |
| detail=validation.model_dump(mode="json"), | |
| ) | |
| schedule = await _social(request).scheduling.schedule(workspace_id, post_id, payload) | |
| await _audit(request, "SOCIAL_SCHEDULE_CREATED", post_id=post_id) | |
| await _audit(request, "SOCIAL_POST_SCHEDULED", post_id=post_id) | |
| await _audit(request, "publishing.scheduled", post_id=post_id) | |
| return schedule | |
| async def reschedule_post( | |
| request: Request, post_id: str, payload: SocialRescheduleRequest | |
| ) -> SocialScheduleView: | |
| workspace_id, _ = _identity(request) | |
| result = await _social(request).operations.reschedule(workspace_id, post_id, payload) | |
| await _audit(request, "publishing.rescheduled", post_id=post_id) | |
| return result | |
| async def cancel_post(request: Request, post_id: str) -> SocialPostView: | |
| workspace_id, _ = _identity(request) | |
| result = await _social(request).publishing.cancel(workspace_id, post_id) | |
| await _audit(request, "SOCIAL_POST_CANCELLED", post_id=post_id) | |
| await _audit(request, "publishing.cancelled", post_id=post_id) | |
| return result | |
| async def publishing_calendar( | |
| request: Request, | |
| starts_at: datetime = Query(), | |
| ends_at: datetime = Query(), | |
| offset: int = Query(default=0, ge=0), | |
| limit: int = Query(default=100, ge=1, le=500), | |
| ) -> PublishingCalendarView: | |
| workspace_id, _ = _identity(request) | |
| return await _social(request).operations.calendar( | |
| workspace_id, | |
| starts_at=starts_at, | |
| ends_at=ends_at, | |
| offset=offset, | |
| limit=limit, | |
| ) | |
| async def publishing_context(request: Request) -> PublishingContextView: | |
| workspace_id, _ = _identity(request) | |
| return PublishingContextView( | |
| timezone=await _social(request).operations.workspace_timezone(workspace_id) | |
| ) | |
| async def publishing_queue( | |
| request: Request, | |
| offset: int = Query(default=0, ge=0), | |
| limit: int = Query(default=50, ge=1, le=200), | |
| queue_status: str | None = Query(default=None, alias="status", max_length=32), | |
| provider: str | None = Query(default=None, max_length=32), | |
| account_id: str | None = Query(default=None, max_length=120), | |
| project_id: str | None = Query(default=None, max_length=36), | |
| search: str | None = Query(default=None, max_length=200), | |
| ) -> PublishingQueueView: | |
| workspace_id, _ = _identity(request) | |
| return await _social(request).operations.queue( | |
| workspace_id, | |
| offset=offset, | |
| limit=limit, | |
| status=queue_status, | |
| provider=provider, | |
| account_id=account_id, | |
| project_id=project_id, | |
| search=search, | |
| ) | |
| async def create_publishing_batch( | |
| request: Request, | |
| payload: PublishingBulkRequest, | |
| idempotency_key: str = Header(alias="Idempotency-Key", min_length=8, max_length=255), | |
| ) -> PublishingBatchView: | |
| workspace_id, user_id = _identity(request) | |
| result = await _social(request).operations.create_batch( | |
| workspace_id, | |
| user_id, | |
| payload, | |
| idempotency_key=idempotency_key, | |
| ) | |
| await _audit(request, "publishing.bulk_started") | |
| return result | |
| async def retry_post_target( | |
| request: Request, | |
| post_id: str, | |
| target_id: str, | |
| idempotency_key: str = Header(alias="Idempotency-Key", min_length=8, max_length=255), | |
| ) -> SocialJobView: | |
| workspace_id, _ = _identity(request) | |
| result = await _social(request).publishing.retry_target( | |
| workspace_id, | |
| post_id, | |
| target_id, | |
| idempotency_key=idempotency_key, | |
| ) | |
| await _audit( | |
| request, | |
| "SOCIAL_TARGET_RETRY_QUEUED", | |
| provider=result.provider.value if result.provider else None, | |
| post_id=post_id, | |
| job_id=result.id, | |
| ) | |
| return result | |
| async def list_jobs( | |
| request: Request, | |
| offset: int = Query(default=0, ge=0), | |
| limit: int = Query(default=100, ge=1, le=500), | |
| ) -> list[SocialJobView]: | |
| workspace_id, _ = _identity(request) | |
| return await _social(request).jobs.list(workspace_id, offset=offset, limit=limit) | |
| async def get_job(request: Request, job_id: str) -> SocialJobView: | |
| workspace_id, _ = _identity(request) | |
| return await _social(request).jobs.get(workspace_id, job_id) | |
| async def account_analytics(request: Request, account_id: str) -> dict[str, object]: | |
| workspace_id, _ = _identity(request) | |
| return await _social(request).analytics.account(workspace_id, account_id) | |
| async def post_analytics(request: Request, post_id: str) -> dict[str, object]: | |
| workspace_id, _ = _identity(request) | |
| return await _social(request).analytics.post(workspace_id, post_id) | |