diff --git a/api/alembic/versions/20260503_0016_add_choose_your_own_adventure.py b/api/alembic/versions/20260503_0016_add_choose_your_own_adventure.py new file mode 100644 index 0000000..9ae71f8 --- /dev/null +++ b/api/alembic/versions/20260503_0016_add_choose_your_own_adventure.py @@ -0,0 +1,208 @@ +"""add choose_your_own_adventure tables + +Revision ID: 0016 +Revises: 0015 +Create Date: 2026-05-03 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "0016" +down_revision: Union[str, None] = "0015" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "choose_your_own_adventure", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "user_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("status", sa.Text(), nullable=False, server_default="awaiting_first_entry"), + sa.Column("language", sa.Text(), nullable=False), + sa.Column("source_language", sa.Text(), nullable=False), + sa.Column("competencies", postgresql.JSONB(), nullable=False, server_default="[]"), + sa.Column("max_entry_count", sa.Integer(), nullable=False, server_default="6"), + sa.Column( + "entry_story_text_target_length", + postgresql.JSONB(), + nullable=False, + server_default='{"min": 700, "max": 800}', + ), + sa.Column("title", sa.Text(), nullable=False, server_default="Untitled adventure"), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("plot_summary", sa.Text(), nullable=True), + sa.Column("genres", postgresql.JSONB(), nullable=False, server_default="[]"), + sa.Column("setting", postgresql.JSONB(), nullable=False, server_default="[]"), + sa.Column("vibes", postgresql.JSONB(), nullable=False, server_default="[]"), + sa.Column("protagonist", postgresql.JSONB(), nullable=False, server_default="[]"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index("ix_cyoa_user_id", "choose_your_own_adventure", ["user_id"]) + op.create_index("ix_cyoa_status", "choose_your_own_adventure", ["status"]) + + # Entry table — generated_from_choice_id FK added after possible_choice table is created + op.create_table( + "choose_your_own_adventure_entry", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "adventure_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("choose_your_own_adventure.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("generated_from_choice_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("status", sa.Text(), nullable=False, server_default="generating"), + sa.Column("entry_index", sa.Integer(), nullable=False), + sa.Column("story_text", sa.Text(), nullable=True), + sa.Column("gamemaster_notes", sa.Text(), nullable=True), + sa.Column("llm_data", postgresql.JSONB(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.UniqueConstraint("adventure_id", "entry_index", name="uq_cyoa_entry_adventure_index"), + ) + op.create_index( + "ix_cyoa_entry_adventure_id", "choose_your_own_adventure_entry", ["adventure_id"] + ) + + op.create_table( + "choose_your_own_adventure_entry_possible_choice", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "entry_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("choose_your_own_adventure_entry.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("index", sa.Integer(), nullable=False), + sa.Column("label", sa.Text(), nullable=False), + sa.Column("text", sa.Text(), nullable=False), + sa.UniqueConstraint("entry_id", "index", name="uq_cyoa_choice_entry_index"), + ) + op.create_index( + "ix_cyoa_choice_entry_id", + "choose_your_own_adventure_entry_possible_choice", + ["entry_id"], + ) + + # Resolve circular FK: entry → possible_choice + op.create_foreign_key( + "fk_cyoa_entry_generated_from_choice", + "choose_your_own_adventure_entry", + "choose_your_own_adventure_entry_possible_choice", + ["generated_from_choice_id"], + ["id"], + ondelete="SET NULL", + ) + + op.create_table( + "choose_your_own_adventure_entry_possible_choice_decision", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "choice_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey( + "choose_your_own_adventure_entry_possible_choice.id", ondelete="CASCADE" + ), + nullable=False, + ), + sa.Column( + "user_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + ) + op.create_index( + "ix_cyoa_decision_choice_id", + "choose_your_own_adventure_entry_possible_choice_decision", + ["choice_id"], + ) + op.create_index( + "ix_cyoa_decision_user_id", + "choose_your_own_adventure_entry_possible_choice_decision", + ["user_id"], + ) + + op.create_table( + "choose_your_own_adventure_entry_translation", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "entry_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("choose_your_own_adventure_entry.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("component_type", sa.Text(), nullable=False, server_default="story_text"), + sa.Column("target_language", sa.Text(), nullable=False), + sa.Column("translated_text", sa.Text(), nullable=False), + sa.UniqueConstraint( + "entry_id", "component_type", "target_language", + name="uq_cyoa_translation_entry_component_lang", + ), + ) + op.create_index( + "ix_cyoa_translation_entry_id", + "choose_your_own_adventure_entry_translation", + ["entry_id"], + ) + + op.create_table( + "choose_your_own_adventure_entry_audio", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "entry_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("choose_your_own_adventure_entry.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("component_type", sa.Text(), nullable=False, server_default="story_text"), + sa.Column("tts_provider", sa.Text(), nullable=False, server_default="google_gemini"), + sa.Column("tts_options", postgresql.JSONB(), nullable=True), + sa.Column("file_name", sa.Text(), nullable=False), + sa.UniqueConstraint("entry_id", "component_type", name="uq_cyoa_audio_entry_component"), + ) + op.create_index( + "ix_cyoa_audio_entry_id", + "choose_your_own_adventure_entry_audio", + ["entry_id"], + ) + + +def downgrade() -> None: + op.drop_table("choose_your_own_adventure_entry_audio") + op.drop_table("choose_your_own_adventure_entry_translation") + op.drop_table("choose_your_own_adventure_entry_possible_choice_decision") + op.drop_constraint( + "fk_cyoa_entry_generated_from_choice", + "choose_your_own_adventure_entry", + type_="foreignkey", + ) + op.drop_table("choose_your_own_adventure_entry_possible_choice") + op.drop_table("choose_your_own_adventure_entry") + op.drop_table("choose_your_own_adventure")