from __future__ import annotations from datetime import datetime, timezone from uuid import uuid4 from sqlalchemy import ( JSON, BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, ) from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column def utcnow() -> datetime: return datetime.now(timezone.utc) def new_id() -> str: return str(uuid4()) class SocialBase(DeclarativeBase): """Separate metadata keeps production schema changes migration-only.""" class SocialAccount(SocialBase): __tablename__ = "social_accounts" __table_args__ = ( UniqueConstraint( "workspace_id", "provider", "external_account_id", name="uq_social_account_workspace_provider_external", ), Index("ix_social_accounts_workspace_id", "workspace_id"), Index("ix_social_accounts_provider_status", "provider", "status"), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) workspace_id: Mapped[str] = mapped_column(String(120), nullable=False) provider: Mapped[str] = mapped_column(String(32), nullable=False) account_type: Mapped[str] = mapped_column(String(64), nullable=False) external_account_id: Mapped[str] = mapped_column(String(255), nullable=False) username: Mapped[str | None] = mapped_column(String(255)) display_name: Mapped[str | None] = mapped_column(String(255)) avatar_url: Mapped[str | None] = mapped_column(String(2048)) status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending") metadata_json: Mapped[dict[str, object]] = mapped_column( "metadata", JSON, nullable=False, default=dict ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow ) last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) class SocialAccountToken(SocialBase): __tablename__ = "social_account_tokens" __table_args__ = ( UniqueConstraint("social_account_id", name="uq_social_account_token"), Index("ix_social_account_tokens_expires_at", "expires_at"), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) social_account_id: Mapped[str] = mapped_column( String(36), ForeignKey("social_accounts.id", ondelete="CASCADE"), nullable=False ) access_token_secret_id: Mapped[str | None] = mapped_column(String(255)) refresh_token_secret_id: Mapped[str | None] = mapped_column(String(255)) encrypted_payload: Mapped[str | None] = mapped_column(Text) expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) scopes: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) token_type: Mapped[str | None] = mapped_column(String(64)) last_refreshed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow ) class SocialAccountCapability(SocialBase): __tablename__ = "social_account_capabilities" __table_args__ = ( UniqueConstraint("social_account_id", "capability", name="uq_social_account_capability"), Index("ix_social_account_capabilities_account", "social_account_id"), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) social_account_id: Mapped[str] = mapped_column( String(36), ForeignKey("social_accounts.id", ondelete="CASCADE"), nullable=False ) capability: Mapped[str] = mapped_column(String(100), nullable=False) enabled: Mapped[bool] = mapped_column(nullable=False, default=False) metadata_json: Mapped[dict[str, object]] = mapped_column( "metadata", JSON, nullable=False, default=dict ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow ) class MediaVariant(SocialBase): __tablename__ = "media_variants" __table_args__ = ( Index("ix_media_variants_workspace_source", "workspace_id", "source_asset_id"), Index("ix_media_variants_platform", "platform"), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) workspace_id: Mapped[str] = mapped_column(String(120), nullable=False) source_asset_id: Mapped[str] = mapped_column(String(255), nullable=False) asset_reference: Mapped[str | None] = mapped_column(String(2048)) template_id: Mapped[str | None] = mapped_column(String(255)) platform: Mapped[str | None] = mapped_column(String(32)) width: Mapped[int | None] = mapped_column(Integer) height: Mapped[int | None] = mapped_column(Integer) duration_seconds: Mapped[float | None] = mapped_column() codec: Mapped[str | None] = mapped_column(String(64)) container: Mapped[str | None] = mapped_column(String(64)) bitrate: Mapped[int | None] = mapped_column(BigInteger) file_size: Mapped[int | None] = mapped_column(BigInteger) metadata_json: Mapped[dict[str, object]] = mapped_column( "metadata", JSON, nullable=False, default=dict ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow ) class SocialMediaAsset(SocialBase): """A workspace-owned reference to an output published by MediaRouter. The media processor remains the source of truth for files. This table only records a tenant binding and immutable download locator so social workers can validate and stream the exact output without trusting a caller-supplied filesystem path or URL. """ __tablename__ = "social_media_assets" __table_args__ = ( UniqueConstraint( "workspace_id", "request_id", "filename", name="uq_social_media_asset_workspace_output" ), Index("ix_social_media_assets_workspace_id", "workspace_id"), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) workspace_id: Mapped[str] = mapped_column(String(120), nullable=False) # Canonical ownership lives in the security/asset domain. This local # social binding is cacheable publishing metadata, not ownership proof. canonical_asset_id: Mapped[str | None] = mapped_column(String(36), nullable=True) request_id: Mapped[str] = mapped_column(String(36), nullable=False) filename: Mapped[str] = mapped_column(String(255), nullable=False) mime_type: Mapped[str] = mapped_column(String(255), nullable=False) file_size: Mapped[int] = mapped_column(BigInteger, nullable=False) metadata_json: Mapped[dict[str, object]] = mapped_column( "metadata", JSON, nullable=False, default=dict ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow ) class SocialCampaign(SocialBase): __tablename__ = "social_campaigns" __table_args__ = (Index("ix_social_campaigns_workspace_id", "workspace_id"),) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) workspace_id: Mapped[str] = mapped_column(String(120), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text) status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft") metadata_json: Mapped[dict[str, object]] = mapped_column( "metadata", JSON, nullable=False, default=dict ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow ) class SocialPost(SocialBase): __tablename__ = "social_posts" __table_args__ = ( UniqueConstraint( "workspace_id", "idempotency_key", name="uq_social_posts_workspace_idempotency" ), Index("ix_social_posts_workspace_created", "workspace_id", "created_at"), Index("ix_social_posts_status", "status"), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) workspace_id: Mapped[str] = mapped_column(String(120), nullable=False) project_id: Mapped[str | None] = mapped_column(String(36), nullable=True) campaign_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("social_campaigns.id", ondelete="SET NULL") ) media_asset_id: Mapped[str | None] = mapped_column(String(255), nullable=True) canonical_caption: Mapped[str | None] = mapped_column(Text) canonical_hashtags: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) brand_kit_version_id: Mapped[str | None] = mapped_column(String(36), nullable=True) source_variant_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("media_variants.id", ondelete="SET NULL") ) status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft") publish_mode: Mapped[str] = mapped_column(String(32), nullable=False, default="draft") idempotency_key: Mapped[str | None] = mapped_column(String(255)) request_fingerprint: Mapped[str | None] = mapped_column(String(64)) metadata_json: Mapped[dict[str, object]] = mapped_column( "metadata", JSON, nullable=False, default=dict ) created_by: Mapped[str | None] = mapped_column(String(120)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow ) published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1) class SocialPostTarget(SocialBase): __tablename__ = "social_post_targets" __table_args__ = ( UniqueConstraint("social_post_id", "social_account_id", name="uq_social_post_target"), Index("ix_social_post_targets_post", "social_post_id"), Index("ix_social_post_targets_account", "social_account_id"), Index("ix_social_post_targets_status", "status"), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) social_post_id: Mapped[str] = mapped_column( String(36), ForeignKey("social_posts.id", ondelete="CASCADE"), nullable=False ) social_account_id: Mapped[str] = mapped_column( String(36), ForeignKey("social_accounts.id", ondelete="RESTRICT"), nullable=False ) provider: Mapped[str] = mapped_column(String(32), nullable=False) status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft") caption_json: Mapped[dict[str, object]] = mapped_column( "caption", JSON, nullable=False, default=dict ) platform_metadata: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False, default=dict) external_post_id: Mapped[str | None] = mapped_column(String(255)) external_url: Mapped[str | None] = mapped_column(String(2048)) error_code: Mapped[str | None] = mapped_column(String(100)) error_message: Mapped[str | None] = mapped_column(Text) published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) cancellation_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow ) class SocialPostMedia(SocialBase): __tablename__ = "social_post_media" __table_args__ = (Index("ix_social_post_media_post", "social_post_id"),) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) social_post_id: Mapped[str] = mapped_column( String(36), ForeignKey("social_posts.id", ondelete="CASCADE"), nullable=False ) media_variant_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("media_variants.id", ondelete="SET NULL") ) media_asset_id: Mapped[str | None] = mapped_column(String(255)) position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) kind: Mapped[str] = mapped_column(String(32), nullable=False, default="video") metadata_json: Mapped[dict[str, object]] = mapped_column( "metadata", JSON, nullable=False, default=dict ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow ) class SocialSchedule(SocialBase): __tablename__ = "social_schedules" __table_args__ = ( UniqueConstraint("social_post_id", name="uq_social_schedule_post"), Index("ix_social_schedules_due", "status", "scheduled_at"), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) social_post_id: Mapped[str] = mapped_column( String(36), ForeignKey("social_posts.id", ondelete="CASCADE"), nullable=False ) scheduled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) timezone: Mapped[str] = mapped_column(String(100), nullable=False) status: Mapped[str] = mapped_column(String(32), nullable=False, default="scheduled") created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow ) revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1) class SocialJob(SocialBase): __tablename__ = "social_jobs" __table_args__ = ( UniqueConstraint( "workspace_id", "idempotency_key", name="uq_social_jobs_workspace_idempotency" ), Index("ix_social_jobs_workspace_status", "workspace_id", "status"), Index("ix_social_jobs_next_attempt", "status", "next_attempt_at"), Index("ix_social_jobs_post", "social_post_id"), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) workspace_id: Mapped[str] = mapped_column(String(120), nullable=False) social_post_id: Mapped[str] = mapped_column( String(36), ForeignKey("social_posts.id", ondelete="CASCADE"), nullable=False ) social_post_target_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("social_post_targets.id", ondelete="CASCADE") ) provider: Mapped[str | None] = mapped_column(String(32)) status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued") attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5) next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) idempotency_key: Mapped[str | None] = mapped_column(String(255)) error_code: Mapped[str | None] = mapped_column(String(100)) error_message: Mapped[str | None] = mapped_column(Text) payload_json: Mapped[dict[str, object]] = mapped_column( "payload", JSON, nullable=False, default=dict ) # Provider resumable-session URLs are bearer-like credentials. They must # survive a worker restart but must never be present in job REST/MCP/SDK # payloads, so they are encrypted separately from payload JSON. provider_state_encrypted: Mapped[str | None] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow ) started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) cancellation_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow ) class SocialJobAttempt(SocialBase): __tablename__ = "social_job_attempts" __table_args__ = ( UniqueConstraint("social_job_id", "attempt_number", name="uq_social_job_attempt_number"), Index("ix_social_job_attempts_job", "social_job_id", "attempt_number"), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) social_job_id: Mapped[str] = mapped_column( String(36), ForeignKey("social_jobs.id", ondelete="CASCADE"), nullable=False ) attempt_number: Mapped[int] = mapped_column(Integer, nullable=False) status: Mapped[str] = mapped_column(String(32), nullable=False) error_code: Mapped[str | None] = mapped_column(String(100)) error_message: Mapped[str | None] = mapped_column(Text) provider_request_id: Mapped[str | None] = mapped_column(String(255)) started_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow ) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) class OAuthState(SocialBase): __tablename__ = "oauth_states" __table_args__ = ( Index("ix_oauth_states_state", "state", unique=True), Index("ix_oauth_states_expires_at", "expires_at"), Index("ix_oauth_states_workspace", "workspace_id"), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) state: Mapped[str] = mapped_column(String(255), nullable=False) provider: Mapped[str] = mapped_column(String(32), nullable=False) workspace_id: Mapped[str] = mapped_column(String(120), nullable=False) user_id: Mapped[str | None] = mapped_column(String(120)) redirect_uri: Mapped[str] = mapped_column(String(2048), nullable=False) requested_account_type: Mapped[str | None] = mapped_column(String(64)) requested_scopes: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) code_verifier_encrypted: Mapped[str | None] = mapped_column(Text) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow ) class SocialWebhookEvent(SocialBase): __tablename__ = "social_webhook_events" __table_args__ = ( UniqueConstraint( "provider", "external_event_id", name="uq_social_webhook_provider_external" ), Index("ix_social_webhook_events_status", "status"), Index("ix_social_webhook_events_received", "received_at"), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) provider: Mapped[str] = mapped_column(String(32), nullable=False) event_type: Mapped[str] = mapped_column(String(100), nullable=False) external_event_id: Mapped[str] = mapped_column(String(255), nullable=False) workspace_id: Mapped[str | None] = mapped_column(String(120)) payload_json: Mapped[dict[str, object]] = mapped_column( "payload", JSON, nullable=False, default=dict ) received_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow ) processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) status: Mapped[str] = mapped_column(String(32), nullable=False, default="received") error_message: Mapped[str | None] = mapped_column(Text) class SocialPostMetric(SocialBase): __tablename__ = "social_post_metrics" __table_args__ = ( Index("ix_social_post_metrics_target_retrieved", "social_post_target_id", "retrieved_at"), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) social_post_id: Mapped[str] = mapped_column( String(36), ForeignKey("social_posts.id", ondelete="CASCADE"), nullable=False ) social_post_target_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("social_post_targets.id", ondelete="CASCADE") ) provider: Mapped[str] = mapped_column(String(32), nullable=False) views: Mapped[int | None] = mapped_column(BigInteger) impressions: Mapped[int | None] = mapped_column(BigInteger) likes: Mapped[int | None] = mapped_column(BigInteger) comments: Mapped[int | None] = mapped_column(BigInteger) shares: Mapped[int | None] = mapped_column(BigInteger) engagement_rate: Mapped[float | None] = mapped_column() published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) retrieved_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow ) raw_metrics: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False, default=dict) class SocialAuditEvent(SocialBase): __tablename__ = "social_audit_events" __table_args__ = ( Index("ix_social_audit_events_workspace_created", "workspace_id", "created_at"), Index("ix_social_audit_events_type", "event_type"), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) workspace_id: Mapped[str] = mapped_column(String(120), nullable=False) api_key_id: Mapped[str | None] = mapped_column(String(36)) event_type: Mapped[str] = mapped_column(String(100), nullable=False) provider: Mapped[str | None] = mapped_column(String(32)) social_account_id: Mapped[str | None] = mapped_column(String(36)) social_post_id: Mapped[str | None] = mapped_column(String(36)) social_job_id: Mapped[str | None] = mapped_column(String(36)) request_id: Mapped[str | None] = mapped_column(String(64)) metadata_json: Mapped[dict[str, object]] = mapped_column( "metadata", JSON, nullable=False, default=dict ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow ) class SocialPublishingBatch(SocialBase): __tablename__ = "social_publishing_batches" __table_args__ = ( UniqueConstraint( "workspace_id", "idempotency_key", name="uq_social_publishing_batch_idempotency" ), Index("ix_social_publishing_batches_workspace_status", "workspace_id", "status"), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) workspace_id: Mapped[str] = mapped_column(String(120), nullable=False) operation: Mapped[str] = mapped_column(String(32), nullable=False) status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued") idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False) requested_by: Mapped[str | None] = mapped_column(String(120)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow ) started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) class SocialPublishingBatchItem(SocialBase): __tablename__ = "social_publishing_batch_items" __table_args__ = ( UniqueConstraint("batch_id", "social_post_id", name="uq_social_publishing_batch_item"), Index("ix_social_publishing_batch_items_status", "status"), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) batch_id: Mapped[str] = mapped_column( String(36), ForeignKey("social_publishing_batches.id", ondelete="CASCADE"), nullable=False ) social_post_id: Mapped[str] = mapped_column( String(36), ForeignKey("social_posts.id", ondelete="CASCADE"), nullable=False ) status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued") error_code: Mapped[str | None] = mapped_column(String(100)) error_message: Mapped[str | None] = mapped_column(Text) metadata_json: Mapped[dict[str, object]] = mapped_column( "metadata", JSON, nullable=False, default=dict ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow )