diff --git a/alembic/versions/024_add_posts_and_attachments.py b/alembic/versions/024_add_posts_and_attachments.py new file mode 100644 index 0000000..06bf6de --- /dev/null +++ b/alembic/versions/024_add_posts_and_attachments.py @@ -0,0 +1,44 @@ +"""Add posts and post_attachments tables. + +Revision ID: 024_add_posts_and_attachments +Revises: 023_add_personality_profile +""" + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB, UUID + +from alembic import op + +revision = "024_add_posts_and_attachments" +down_revision = "023_add_personality_profile" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "posts", + sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()), + sa.Column("creator_id", UUID(as_uuid=True), sa.ForeignKey("creators.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("title", sa.String(500), nullable=False), + sa.Column("body_json", JSONB, nullable=False), + sa.Column("is_published", sa.Boolean, nullable=False, server_default="false"), + sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.func.now()), + ) + + op.create_table( + "post_attachments", + sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.func.gen_random_uuid()), + sa.Column("post_id", UUID(as_uuid=True), sa.ForeignKey("posts.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("filename", sa.String(500), nullable=False), + sa.Column("object_key", sa.String(1000), nullable=False), + sa.Column("content_type", sa.String(255), nullable=False), + sa.Column("size_bytes", sa.BigInteger, nullable=False), + sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table("post_attachments") + op.drop_table("posts") diff --git a/backend/config.py b/backend/config.py index e115190..11e963e 100644 --- a/backend/config.py +++ b/backend/config.py @@ -71,6 +71,13 @@ class Settings(BaseSettings): # Debug mode — when True, pipeline captures full LLM prompts and responses debug_mode: bool = False + # MinIO (file storage for post attachments) + minio_url: str = "chrysopedia-minio:9000" + minio_access_key: str = "chrysopedia" + minio_secret_key: str = "changeme-minio" + minio_bucket: str = "chrysopedia" + minio_secure: bool = False + # File storage transcript_storage_path: str = "/data/transcripts" video_metadata_path: str = "/data/video_meta" diff --git a/backend/minio_client.py b/backend/minio_client.py new file mode 100644 index 0000000..d467058 --- /dev/null +++ b/backend/minio_client.py @@ -0,0 +1,104 @@ +"""MinIO client singleton with lazy initialization. + +Provides file upload, presigned download URL generation, and automatic +bucket creation for the Chrysopedia post attachment storage. +""" + +from __future__ import annotations + +import io +import logging +from datetime import timedelta + +from minio import Minio +from minio.error import S3Error + +from config import get_settings + +logger = logging.getLogger(__name__) + +_client: Minio | None = None +_bucket_ensured: bool = False + + +def get_minio_client() -> Minio: + """Return the singleton MinIO client, creating it on first call.""" + global _client + if _client is None: + settings = get_settings() + _client = Minio( + settings.minio_url, + access_key=settings.minio_access_key, + secret_key=settings.minio_secret_key, + secure=settings.minio_secure, + ) + logger.info("MinIO client initialized (endpoint=%s)", settings.minio_url) + return _client + + +def ensure_bucket() -> None: + """Create the configured bucket if it doesn't already exist.""" + global _bucket_ensured + if _bucket_ensured: + return + settings = get_settings() + client = get_minio_client() + bucket = settings.minio_bucket + try: + if not client.bucket_exists(bucket): + client.make_bucket(bucket) + logger.info("Created MinIO bucket: %s", bucket) + else: + logger.debug("MinIO bucket already exists: %s", bucket) + _bucket_ensured = True + except S3Error as exc: + logger.error("MinIO bucket check/create failed: %s", exc) + raise + + +def upload_file( + object_key: str, + data: bytes | io.BytesIO, + length: int, + content_type: str = "application/octet-stream", +) -> None: + """Upload a file to MinIO. + + Args: + object_key: The storage path within the bucket. + data: File content as bytes or BytesIO stream. + length: Size in bytes. + content_type: MIME type for the object. + """ + ensure_bucket() + settings = get_settings() + client = get_minio_client() + stream = io.BytesIO(data) if isinstance(data, bytes) else data + client.put_object( + settings.minio_bucket, + object_key, + stream, + length, + content_type=content_type, + ) + logger.info("Uploaded %s (%d bytes, %s)", object_key, length, content_type) + + +def generate_download_url(object_key: str, expires: int = 3600) -> str: + """Generate a presigned GET URL for downloading a file. + + Args: + object_key: The storage path within the bucket. + expires: URL validity in seconds (default 1 hour). + + Returns: + Presigned URL string. + """ + settings = get_settings() + client = get_minio_client() + url: str = client.presigned_get_object( + settings.minio_bucket, + object_key, + expires=timedelta(seconds=expires), + ) + return url diff --git a/backend/models.py b/backend/models.py index c070d25..94df94f 100644 --- a/backend/models.py +++ b/backend/models.py @@ -12,6 +12,7 @@ import uuid from datetime import datetime, timezone from sqlalchemy import ( + BigInteger, Boolean, Enum, Float, @@ -144,6 +145,7 @@ class Creator(Base): # relationships videos: Mapped[list[SourceVideo]] = sa_relationship(back_populates="creator") technique_pages: Mapped[list[TechniquePage]] = sa_relationship(back_populates="creator") + posts: Mapped[list[Post]] = sa_relationship(back_populates="creator") class User(Base): @@ -763,3 +765,52 @@ class CreatorFollow(Base): # relationships user: Mapped[User] = sa_relationship() creator: Mapped[Creator] = sa_relationship() + + +# ── Posts (Creator content feed) ───────────────────────────────────────────── + +class Post(Base): + """A rich text post by a creator, optionally with file attachments.""" + __tablename__ = "posts" + + id: Mapped[uuid.UUID] = _uuid_pk() + creator_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("creators.id", ondelete="CASCADE"), nullable=False, index=True, + ) + title: Mapped[str] = mapped_column(String(500), nullable=False) + body_json: Mapped[dict] = mapped_column(JSONB, nullable=False) + is_published: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false", + ) + created_at: Mapped[datetime] = mapped_column( + default=_now, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + default=_now, server_default=func.now(), onupdate=_now + ) + + # relationships + creator: Mapped[Creator] = sa_relationship(back_populates="posts") + attachments: Mapped[list[PostAttachment]] = sa_relationship( + back_populates="post", cascade="all, delete-orphan" + ) + + +class PostAttachment(Base): + """A file attachment on a post, stored in MinIO.""" + __tablename__ = "post_attachments" + + id: Mapped[uuid.UUID] = _uuid_pk() + post_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("posts.id", ondelete="CASCADE"), nullable=False, index=True, + ) + filename: Mapped[str] = mapped_column(String(500), nullable=False) + object_key: Mapped[str] = mapped_column(String(1000), nullable=False) + content_type: Mapped[str] = mapped_column(String(255), nullable=False) + size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False) + created_at: Mapped[datetime] = mapped_column( + default=_now, server_default=func.now() + ) + + # relationships + post: Mapped[Post] = sa_relationship(back_populates="attachments") diff --git a/backend/requirements.txt b/backend/requirements.txt index a2547d0..12a9ecd 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -17,6 +17,7 @@ psycopg2-binary>=2.9,<3.0 watchdog>=4.0,<5.0 PyJWT>=2.8,<3.0 bcrypt>=4.0,<6.0 +minio>=7.2,<8.0 # Test dependencies pytest>=8.0,<10.0 pytest-asyncio>=0.24,<1.0 diff --git a/backend/schemas.py b/backend/schemas.py index 3fd8b91..38dae84 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -769,3 +769,51 @@ class PersonalityProfile(BaseModel): tone: ToneProfile = Field(default_factory=ToneProfile) style_markers: StyleMarkersProfile = Field(default_factory=StyleMarkersProfile) summary: str = "" + + +# ── Posts (Creator content feed) ───────────────────────────────────────────── + +class PostAttachmentRead(BaseModel): + """Read schema for a file attachment on a post.""" + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + filename: str + content_type: str + size_bytes: int + download_url: str | None = None + created_at: datetime + + +class PostCreate(BaseModel): + """Create a new post.""" + title: str = Field(..., min_length=1, max_length=500) + body_json: dict + is_published: bool = False + + +class PostUpdate(BaseModel): + """Partial update for an existing post.""" + title: str | None = Field(None, min_length=1, max_length=500) + body_json: dict | None = None + is_published: bool | None = None + + +class PostRead(BaseModel): + """Full post with attachments.""" + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + creator_id: uuid.UUID + title: str + body_json: dict + is_published: bool = False + created_at: datetime + updated_at: datetime + attachments: list[PostAttachmentRead] = Field(default_factory=list) + + +class PostListResponse(BaseModel): + """Paginated list of posts.""" + items: list[PostRead] = Field(default_factory=list) + total: int = 0 diff --git a/docker-compose.yml b/docker-compose.yml index fd0a3cf..a4e8327 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -204,6 +204,27 @@ services: start_period: 15s stop_grace_period: 15s + # ── MinIO (file storage for post attachments) ── + chrysopedia-minio: + image: minio/minio + container_name: chrysopedia-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-chrysopedia} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-changeme-minio} + volumes: + - /vmPool/r/services/chrysopedia_minio:/data + networks: + - chrysopedia + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:9000/minio/health/live || exit 1"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 10s + stop_grace_period: 15s + # ── React web UI (nginx) ── chrysopedia-web: build: diff --git a/docker/nginx.conf b/docker/nginx.conf index 08fb966..9b8bffe 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -8,8 +8,8 @@ server { # after container recreates resolver 127.0.0.11 valid=30s ipv6=off; - # Allow large transcript uploads (up to 50MB) - client_max_body_size 50m; + # Allow large file uploads (up to 100MB) + client_max_body_size 100m; # SPA fallback location / {