From 6641eba9dfd2b82ef97c0c78b587ae5a2323dfdd Mon Sep 17 00:00:00 2001 From: qzl Date: Thu, 26 Feb 2026 17:58:37 +0800 Subject: [PATCH] feat: split initial social schema migration chain replace monolithic migration with ordered scripts, include profiles/sessions in migration, and verify full downgrade/upgrade cycle for clean Supabase bootstrap --- backend/alembic/env.py | 10 + .../versions/20260226_0001_initial_schema.py | 285 ++++++++++ .../20260226_0002_session_and_agent_tables.py | 344 ++++++++++++ .../20260226_0003_social_graph_tables.py | 324 ++++++++++++ .../20260226_0004_collaboration_tables.py | 488 ++++++++++++++++++ .../20260226_0005_hardening_indexes.py | 29 ++ backend/src/models/__init__.py | 20 + backend/src/models/agent_chat_session.py | 17 +- backend/src/models/automation_jobs.py | 72 +++ backend/src/models/friendships.py | 64 +++ backend/src/models/group_members.py | 77 +++ backend/src/models/groups.py | 49 ++ backend/src/models/inbox_messages.py | 75 +++ backend/src/models/memories.py | 61 +++ backend/src/models/profile.py | 11 +- backend/src/models/schedule_items.py | 82 +++ backend/src/models/schedule_subscriptions.py | 58 +++ backend/src/models/todo_sources.py | 25 + backend/src/models/todos.py | 67 +++ backend/src/models/user_agents.py | 62 +++ .../test_agent_chat_migration_contract.py | 28 +- .../test_profile_migration_contract.py | 17 +- 22 files changed, 2242 insertions(+), 23 deletions(-) create mode 100644 backend/alembic/versions/20260226_0001_initial_schema.py create mode 100644 backend/alembic/versions/20260226_0002_session_and_agent_tables.py create mode 100644 backend/alembic/versions/20260226_0003_social_graph_tables.py create mode 100644 backend/alembic/versions/20260226_0004_collaboration_tables.py create mode 100644 backend/alembic/versions/20260226_0005_hardening_indexes.py create mode 100644 backend/src/models/automation_jobs.py create mode 100644 backend/src/models/friendships.py create mode 100644 backend/src/models/group_members.py create mode 100644 backend/src/models/groups.py create mode 100644 backend/src/models/inbox_messages.py create mode 100644 backend/src/models/memories.py create mode 100644 backend/src/models/schedule_items.py create mode 100644 backend/src/models/schedule_subscriptions.py create mode 100644 backend/src/models/todo_sources.py create mode 100644 backend/src/models/todos.py create mode 100644 backend/src/models/user_agents.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 3a55d52..ca7eb96 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -20,9 +20,19 @@ from core.db.base import Base # noqa: E402 from models import ( # noqa: F401,E402 AgentChatMessage, AgentChatSession, + AutomationJob, + Group, + GroupMember, + InboxMessage, Llm, LlmFactory, + Memory, Profile, + ScheduleItem, + ScheduleSubscription, + Todo, + TodoSource, + UserAgent, ) if TYPE_CHECKING: diff --git a/backend/alembic/versions/20260226_0001_initial_schema.py b/backend/alembic/versions/20260226_0001_initial_schema.py new file mode 100644 index 0000000..0041316 --- /dev/null +++ b/backend/alembic/versions/20260226_0001_initial_schema.py @@ -0,0 +1,285 @@ +"""initial schema part 1: foundation tables + +Revision ID: 202602260001 +Revises: +Create Date: 2026-02-26 20:10:00 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "202602260001" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "llm_factory", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("name", sa.String(length=50), nullable=False), + sa.Column("request_url", sa.String(length=255), nullable=False), + sa.Column("avatar", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_index("ix_llm_factory_name", "llm_factory", ["name"], unique=True) + _enable_rls("llm_factory") + + op.create_table( + "llms", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("factory_id", sa.UUID(), nullable=False), + sa.Column("model_code", sa.String(length=50), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("model_code"), + ) + op.create_index("ix_llms_factory_id", "llms", ["factory_id"], unique=False) + op.create_index("ix_llms_model_code", "llms", ["model_code"], unique=True) + op.create_foreign_key( + "fk_llms_factory_id", + "llms", + "llm_factory", + ["factory_id"], + ["id"], + ondelete="RESTRICT", + ) + _enable_rls("llms") + + op.create_table( + "profiles", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("username", sa.String(length=30), nullable=False), + sa.Column("avatar_url", sa.Text(), nullable=True), + sa.Column("bio", sa.String(length=200), nullable=True), + sa.Column( + "settings", + postgresql.JSONB(astext_type=sa.Text()), + server_default="{}", + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_profiles_username", "profiles", ["username"], unique=False) + op.create_index( + "ix_profiles_settings_gin", + "profiles", + ["settings"], + unique=False, + postgresql_using="gin", + ) + op.create_foreign_key( + "fk_profiles_id", + "profiles", + "users", + ["id"], + ["id"], + referent_schema="auth", + ondelete="CASCADE", + ) + _enable_rls("profiles") + op.execute( + """ + CREATE OR REPLACE FUNCTION public.create_profile_for_new_user() + RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = public + AS $$ + BEGIN + INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at) + VALUES ( + NEW.id, + COALESCE( + NEW.raw_user_meta_data ->> 'username', + split_part(NEW.email, '@', 1), + 'user_' || substring(NEW.id::text, 1, 8) + ), + NULL, + NULL, + '{}'::jsonb, + now(), + now() + ) + ON CONFLICT (id) DO NOTHING; + + RETURN NEW; + END; + $$; + """ + ) + op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users") + op.execute( + """ + CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.create_profile_for_new_user(); + """ + ) + + op.create_table( + "automation_jobs", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("owner_id", sa.UUID(), nullable=False), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("prompt", sa.Text(), nullable=False), + sa.Column("schedule_type", sa.String(length=20), nullable=False), + sa.Column("run_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("next_run_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("timezone", sa.String(length=50), nullable=False), + sa.Column("last_run_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("created_by", sa.UUID(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("id", "owner_id", name="uq_automation_jobs_id_owner"), + ) + op.create_index( + "ix_automation_jobs_owner_status", + "automation_jobs", + ["owner_id", "status"], + unique=False, + ) + op.create_index( + "ix_automation_jobs_status_next_run", + "automation_jobs", + ["status", "next_run_at"], + unique=False, + ) + op.execute( + "ALTER TABLE automation_jobs ADD CONSTRAINT chk_automation_job_schedule_type CHECK (schedule_type IN ('daily', 'weekly'))" + ) + op.execute( + "ALTER TABLE automation_jobs ADD CONSTRAINT chk_automation_job_status CHECK (status IN ('active', 'disabled'))" + ) + op.create_foreign_key( + "fk_automation_jobs_owner_id", + "automation_jobs", + "users", + ["owner_id"], + ["id"], + referent_schema="auth", + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_automation_jobs_created_by", + "automation_jobs", + "users", + ["created_by"], + ["id"], + referent_schema="auth", + ondelete="SET NULL", + ) + _enable_rls("automation_jobs") + + op.execute("REVOKE ALL ON TABLE public.alembic_version FROM anon") + op.execute("REVOKE ALL ON TABLE public.alembic_version FROM authenticated") + + +def downgrade() -> None: + _drop_rls("automation_jobs") + op.drop_constraint( + "fk_automation_jobs_created_by", "automation_jobs", type_="foreignkey" + ) + op.drop_constraint( + "fk_automation_jobs_owner_id", "automation_jobs", type_="foreignkey" + ) + op.drop_table("automation_jobs") + + _drop_rls("profiles") + op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users") + op.execute("DROP FUNCTION IF EXISTS public.create_profile_for_new_user()") + op.drop_constraint("fk_profiles_id", "profiles", type_="foreignkey") + op.drop_table("profiles") + + _drop_rls("llms") + op.drop_constraint("fk_llms_factory_id", "llms", type_="foreignkey") + op.drop_table("llms") + _drop_rls("llm_factory") + op.drop_table("llm_factory") + + +def _enable_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + for action in ["select", "insert", "update", "delete"]: + op.execute( + f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" + ) + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + for role in ["anon", "authenticated"]: + op.execute( + f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)" + ) + op.execute( + f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)" + ) + + +def _drop_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + op.execute(f"DROP POLICY IF EXISTS {role}_delete_{table_name} ON {table_name}") + op.execute(f"DROP POLICY IF EXISTS {role}_update_{table_name} ON {table_name}") + op.execute(f"DROP POLICY IF EXISTS {role}_insert_{table_name} ON {table_name}") + op.execute(f"DROP POLICY IF EXISTS {role}_select_{table_name} ON {table_name}") + op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/alembic/versions/20260226_0002_session_and_agent_tables.py b/backend/alembic/versions/20260226_0002_session_and_agent_tables.py new file mode 100644 index 0000000..79796c7 --- /dev/null +++ b/backend/alembic/versions/20260226_0002_session_and_agent_tables.py @@ -0,0 +1,344 @@ +"""initial schema part 2: session and agent tables + +Revision ID: 202602260002 +Revises: 202602260001 +Create Date: 2026-02-26 20:11:00 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "202602260002" +down_revision: Union[str, Sequence[str], None] = "202602260001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "sessions", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("session_type", sa.String(length=20), nullable=False), + sa.Column("job_id", sa.UUID(), nullable=True), + sa.Column("title", sa.String(length=255), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column( + "last_activity_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "message_count", sa.Integer(), server_default=sa.text("0"), nullable=False + ), + sa.Column( + "total_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False + ), + sa.Column( + "total_cost", + sa.Numeric(precision=12, scale=6), + server_default=sa.text("0"), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_sessions_job_id", "sessions", ["job_id"], unique=False) + op.execute( + "CREATE INDEX ix_sessions_user_session_type_last_activity ON sessions (user_id, session_type, last_activity_at DESC)" + ) + op.execute( + "ALTER TABLE sessions ADD CONSTRAINT chk_session_type CHECK (session_type IN ('chat', 'automation'))" + ) + op.execute( + "ALTER TABLE sessions ADD CONSTRAINT chk_sessions_type_job_consistency CHECK ((session_type = 'chat' AND job_id IS NULL) OR (session_type = 'automation' AND job_id IS NOT NULL))" + ) + op.execute( + "ALTER TABLE sessions ADD CONSTRAINT chk_sessions_status CHECK (status IN ('pending', 'running', 'completed', 'failed'))" + ) + op.create_foreign_key( + "fk_sessions_user_id", + "sessions", + "users", + ["user_id"], + ["id"], + referent_schema="auth", + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_sessions_job_id_user_id", + "sessions", + "automation_jobs", + ["job_id", "user_id"], + ["id", "owner_id"], + ondelete="RESTRICT", + ) + op.create_index( + "ix_sessions_job_user", + "sessions", + ["job_id", "user_id"], + unique=False, + ) + _enable_rls("sessions") + + op.create_table( + "messages", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("session_id", sa.UUID(), nullable=False), + sa.Column("seq", sa.Integer(), nullable=False), + sa.Column("role", sa.String(length=20), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("model_code", sa.String(length=50), nullable=True), + sa.Column("tool_name", sa.String(length=100), nullable=True), + sa.Column( + "input_tokens", sa.Integer(), nullable=False, server_default=sa.text("0") + ), + sa.Column( + "output_tokens", sa.Integer(), nullable=False, server_default=sa.text("0") + ), + sa.Column( + "cost", + sa.Numeric(precision=12, scale=6), + nullable=False, + server_default=sa.text("0"), + ), + sa.Column( + "currency", + sa.String(length=3), + nullable=False, + server_default=sa.text("'USD'"), + ), + sa.Column("latency_ms", sa.Integer(), nullable=True), + sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("session_id", "seq", name="uq_messages_session_seq"), + ) + op.create_index("ix_messages_session_id", "messages", ["session_id"], unique=False) + op.execute( + "ALTER TABLE messages ADD CONSTRAINT chk_message_role CHECK (role IN ('user', 'assistant', 'system', 'tool'))" + ) + op.create_foreign_key( + "fk_messages_session_id", + "messages", + "sessions", + ["session_id"], + ["id"], + ondelete="CASCADE", + ) + _enable_rls("messages") + + op.create_table( + "user_agents", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("llm_id", sa.UUID(), nullable=False), + sa.Column("agent_type", sa.String(length=20), nullable=False), + sa.Column( + "config", + postgresql.JSONB(astext_type=sa.Text()), + server_default="{}", + nullable=False, + ), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("created_by", sa.UUID(), nullable=True), + sa.Column("updated_by", sa.UUID(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", name="uq_user_agents_user_id"), + ) + op.create_index( + "ix_user_agents_agent_type", "user_agents", ["agent_type"], unique=False + ) + op.create_index("ix_user_agents_status", "user_agents", ["status"], unique=False) + op.execute( + "ALTER TABLE user_agents ADD CONSTRAINT chk_agent_type CHECK (agent_type IN ('INTENT_RECOGNITION', 'TASK_EXECUTION', 'RESULT_REPORTING'))" + ) + op.create_foreign_key( + "fk_user_agents_user_id", + "user_agents", + "users", + ["user_id"], + ["id"], + referent_schema="auth", + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_user_agents_llm_id", + "user_agents", + "llms", + ["llm_id"], + ["id"], + ondelete="RESTRICT", + ) + op.create_foreign_key( + "fk_user_agents_created_by", + "user_agents", + "users", + ["created_by"], + ["id"], + referent_schema="auth", + ondelete="SET NULL", + ) + op.create_foreign_key( + "fk_user_agents_updated_by", + "user_agents", + "users", + ["updated_by"], + ["id"], + referent_schema="auth", + ondelete="SET NULL", + ) + _enable_rls("user_agents") + + op.create_table( + "memories", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("owner_id", sa.UUID(), nullable=False), + sa.Column("agent_id", sa.UUID(), nullable=True), + sa.Column("memory_type", sa.String(length=20), nullable=False), + sa.Column("title", sa.String(length=255), nullable=True), + sa.Column("content", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("source", sa.String(length=20), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_memories_owner_type_status", + "memories", + ["owner_id", "memory_type", "status"], + unique=False, + ) + op.create_index( + "ix_memories_agent_type_status", + "memories", + ["agent_id", "memory_type", "status"], + unique=False, + ) + op.execute( + "ALTER TABLE memories ADD CONSTRAINT chk_memory_type_agent_id CHECK ((memory_type = 'work' AND agent_id IS NOT NULL) OR (memory_type = 'user' AND agent_id IS NULL))" + ) + op.create_foreign_key( + "fk_memories_owner_id", + "memories", + "users", + ["owner_id"], + ["id"], + referent_schema="auth", + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_memories_agent_id", + "memories", + "user_agents", + ["agent_id"], + ["id"], + ondelete="CASCADE", + ) + _enable_rls("memories") + + +def downgrade() -> None: + _drop_rls("memories") + op.drop_constraint("fk_memories_agent_id", "memories", type_="foreignkey") + op.drop_constraint("fk_memories_owner_id", "memories", type_="foreignkey") + op.drop_table("memories") + + _drop_rls("user_agents") + op.drop_constraint("fk_user_agents_updated_by", "user_agents", type_="foreignkey") + op.drop_constraint("fk_user_agents_created_by", "user_agents", type_="foreignkey") + op.drop_constraint("fk_user_agents_llm_id", "user_agents", type_="foreignkey") + op.drop_constraint("fk_user_agents_user_id", "user_agents", type_="foreignkey") + op.drop_table("user_agents") + + _drop_rls("messages") + op.drop_constraint("fk_messages_session_id", "messages", type_="foreignkey") + op.drop_table("messages") + + _drop_rls("sessions") + op.drop_constraint("fk_sessions_job_id_user_id", "sessions", type_="foreignkey") + op.drop_constraint("fk_sessions_user_id", "sessions", type_="foreignkey") + op.drop_table("sessions") + + +def _enable_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + for action in ["select", "insert", "update", "delete"]: + op.execute( + f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" + ) + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + for role in ["anon", "authenticated"]: + op.execute( + f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)" + ) + op.execute( + f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)" + ) + + +def _drop_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + op.execute(f"DROP POLICY IF EXISTS {role}_delete_{table_name} ON {table_name}") + op.execute(f"DROP POLICY IF EXISTS {role}_update_{table_name} ON {table_name}") + op.execute(f"DROP POLICY IF EXISTS {role}_insert_{table_name} ON {table_name}") + op.execute(f"DROP POLICY IF EXISTS {role}_select_{table_name} ON {table_name}") + op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/alembic/versions/20260226_0003_social_graph_tables.py b/backend/alembic/versions/20260226_0003_social_graph_tables.py new file mode 100644 index 0000000..a698b54 --- /dev/null +++ b/backend/alembic/versions/20260226_0003_social_graph_tables.py @@ -0,0 +1,324 @@ +"""initial schema part 3: social graph tables + +Revision ID: 202602260003 +Revises: 202602260002 +Create Date: 2026-02-26 20:12:00 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "202602260003" +down_revision: Union[str, Sequence[str], None] = "202602260002" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "friendships", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_low_id", sa.UUID(), nullable=False), + sa.Column("user_high_id", sa.UUID(), nullable=False), + sa.Column("initiator_id", sa.UUID(), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("requested_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("blocked_by", sa.UUID(), nullable=True), + sa.Column("created_by", sa.UUID(), nullable=True), + sa.Column("updated_by", sa.UUID(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_low_id", "user_high_id", name="uq_friendships_users"), + ) + op.create_index( + "ix_friendships_user_low_status", + "friendships", + ["user_low_id", "status"], + unique=False, + ) + op.create_index( + "ix_friendships_user_high_status", + "friendships", + ["user_high_id", "status"], + unique=False, + ) + op.execute( + "CREATE INDEX ix_friendships_pending ON friendships (status) WHERE status = 'pending'" + ) + op.execute( + "ALTER TABLE friendships ADD CONSTRAINT chk_user_low_less_than_high CHECK (user_low_id < user_high_id)" + ) + op.execute( + "ALTER TABLE friendships ADD CONSTRAINT chk_initiator_id_valid CHECK (initiator_id IN (user_low_id, user_high_id))" + ) + op.execute( + "ALTER TABLE friendships ADD CONSTRAINT chk_user_ids_different CHECK (user_low_id <> user_high_id)" + ) + op.create_foreign_key( + "fk_friendships_user_low_id", + "friendships", + "users", + ["user_low_id"], + ["id"], + referent_schema="auth", + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_friendships_user_high_id", + "friendships", + "users", + ["user_high_id"], + ["id"], + referent_schema="auth", + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_friendships_initiator_id", + "friendships", + "users", + ["initiator_id"], + ["id"], + referent_schema="auth", + ondelete="SET NULL", + ) + op.create_foreign_key( + "fk_friendships_created_by", + "friendships", + "users", + ["created_by"], + ["id"], + referent_schema="auth", + ondelete="SET NULL", + ) + op.create_foreign_key( + "fk_friendships_updated_by", + "friendships", + "users", + ["updated_by"], + ["id"], + referent_schema="auth", + ondelete="SET NULL", + ) + _enable_rls("friendships") + + op.create_table( + "groups", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("owner_id", sa.UUID(), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("created_by", sa.UUID(), nullable=True), + sa.Column("updated_by", sa.UUID(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_groups_owner_status", "groups", ["owner_id", "status"], unique=False + ) + op.create_foreign_key( + "fk_groups_owner_id", + "groups", + "users", + ["owner_id"], + ["id"], + referent_schema="auth", + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_groups_created_by", + "groups", + "users", + ["created_by"], + ["id"], + referent_schema="auth", + ondelete="SET NULL", + ) + op.create_foreign_key( + "fk_groups_updated_by", + "groups", + "users", + ["updated_by"], + ["id"], + referent_schema="auth", + ondelete="SET NULL", + ) + _enable_rls("groups") + + op.create_table( + "group_members", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("group_id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("role", sa.String(length=20), nullable=False), + sa.Column("join_source", sa.String(length=20), nullable=False), + sa.Column("invited_by", sa.UUID(), nullable=True), + sa.Column("joined_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("removed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("created_by", sa.UUID(), nullable=True), + sa.Column("updated_by", sa.UUID(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("group_id", "user_id", name="uq_group_members_group_user"), + ) + op.create_index( + "ix_group_members_group_role_status", + "group_members", + ["group_id", "role", "status"], + unique=False, + ) + op.create_index( + "ix_group_members_user_status", + "group_members", + ["user_id", "status"], + unique=False, + ) + op.execute( + "ALTER TABLE group_members ADD CONSTRAINT chk_group_member_role CHECK (role IN ('owner', 'admin', 'member'))" + ) + op.create_foreign_key( + "fk_group_members_group_id", + "group_members", + "groups", + ["group_id"], + ["id"], + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_group_members_user_id", + "group_members", + "users", + ["user_id"], + ["id"], + referent_schema="auth", + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_group_members_invited_by", + "group_members", + "users", + ["invited_by"], + ["id"], + referent_schema="auth", + ondelete="SET NULL", + ) + op.create_foreign_key( + "fk_group_members_created_by", + "group_members", + "users", + ["created_by"], + ["id"], + referent_schema="auth", + ondelete="SET NULL", + ) + op.create_foreign_key( + "fk_group_members_updated_by", + "group_members", + "users", + ["updated_by"], + ["id"], + referent_schema="auth", + ondelete="SET NULL", + ) + _enable_rls("group_members") + + +def downgrade() -> None: + _drop_rls("group_members") + op.drop_constraint( + "fk_group_members_updated_by", "group_members", type_="foreignkey" + ) + op.drop_constraint( + "fk_group_members_created_by", "group_members", type_="foreignkey" + ) + op.drop_constraint( + "fk_group_members_invited_by", "group_members", type_="foreignkey" + ) + op.drop_constraint("fk_group_members_user_id", "group_members", type_="foreignkey") + op.drop_constraint("fk_group_members_group_id", "group_members", type_="foreignkey") + op.drop_table("group_members") + + _drop_rls("groups") + op.drop_constraint("fk_groups_updated_by", "groups", type_="foreignkey") + op.drop_constraint("fk_groups_created_by", "groups", type_="foreignkey") + op.drop_constraint("fk_groups_owner_id", "groups", type_="foreignkey") + op.drop_table("groups") + + _drop_rls("friendships") + op.drop_constraint("fk_friendships_updated_by", "friendships", type_="foreignkey") + op.drop_constraint("fk_friendships_created_by", "friendships", type_="foreignkey") + op.drop_constraint("fk_friendships_initiator_id", "friendships", type_="foreignkey") + op.drop_constraint("fk_friendships_user_high_id", "friendships", type_="foreignkey") + op.drop_constraint("fk_friendships_user_low_id", "friendships", type_="foreignkey") + op.drop_table("friendships") + + +def _enable_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + for action in ["select", "insert", "update", "delete"]: + op.execute( + f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" + ) + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + for role in ["anon", "authenticated"]: + op.execute( + f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)" + ) + op.execute( + f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)" + ) + + +def _drop_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + op.execute(f"DROP POLICY IF EXISTS {role}_delete_{table_name} ON {table_name}") + op.execute(f"DROP POLICY IF EXISTS {role}_update_{table_name} ON {table_name}") + op.execute(f"DROP POLICY IF EXISTS {role}_insert_{table_name} ON {table_name}") + op.execute(f"DROP POLICY IF EXISTS {role}_select_{table_name} ON {table_name}") + op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/alembic/versions/20260226_0004_collaboration_tables.py b/backend/alembic/versions/20260226_0004_collaboration_tables.py new file mode 100644 index 0000000..3178685 --- /dev/null +++ b/backend/alembic/versions/20260226_0004_collaboration_tables.py @@ -0,0 +1,488 @@ +"""initial schema part 4: collaboration tables + +Revision ID: 202602260004 +Revises: 202602260003 +Create Date: 2026-02-26 20:13:00 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "202602260004" +down_revision: Union[str, Sequence[str], None] = "202602260003" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "schedule_items", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("owner_id", sa.UUID(), nullable=False), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("start_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("end_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("timezone", sa.String(length=50), nullable=False), + sa.Column( + "metadata", + postgresql.JSONB(astext_type=sa.Text()), + server_default="{}", + nullable=False, + ), + sa.Column("recurrence_rule", sa.String(length=255), nullable=True), + sa.Column("source_type", sa.String(length=20), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("created_by", sa.UUID(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_schedule_items_owner_start", + "schedule_items", + ["owner_id", "start_at"], + unique=False, + ) + op.create_index( + "ix_schedule_items_status_start", + "schedule_items", + ["status", "start_at"], + unique=False, + ) + op.execute( + "ALTER TABLE schedule_items ADD CONSTRAINT chk_schedule_item_source_type CHECK (source_type IN ('manual', 'imported', 'agent_generated'))" + ) + op.execute( + "ALTER TABLE schedule_items ADD CONSTRAINT chk_schedule_item_status CHECK (status IN ('active', 'completed', 'canceled', 'archived'))" + ) + op.create_foreign_key( + "fk_schedule_items_owner_id", + "schedule_items", + "users", + ["owner_id"], + ["id"], + referent_schema="auth", + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_schedule_items_created_by", + "schedule_items", + "users", + ["created_by"], + ["id"], + referent_schema="auth", + ondelete="SET NULL", + ) + _enable_rls("schedule_items") + + op.create_table( + "schedule_subscriptions", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("item_id", sa.UUID(), nullable=False), + sa.Column("subscriber_id", sa.UUID(), nullable=False), + sa.Column( + "permission", sa.Integer(), nullable=False, server_default=sa.text("1") + ), + sa.Column( + "notify_level", + sa.String(length=20), + nullable=False, + server_default=sa.text("'all'"), + ), + sa.Column( + "status", + sa.String(length=20), + nullable=False, + server_default=sa.text("'active'"), + ), + sa.Column("created_by", sa.UUID(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "item_id", "subscriber_id", name="uq_schedule_subscriptions_item_subscriber" + ), + ) + op.create_index( + "ix_schedule_subscribers_subscriber_status", + "schedule_subscriptions", + ["subscriber_id", "status"], + unique=False, + ) + op.create_index( + "ix_schedule_subscribers_item_status", + "schedule_subscriptions", + ["item_id", "status"], + unique=False, + ) + op.execute( + "ALTER TABLE schedule_subscriptions ADD CONSTRAINT chk_schedule_subscription_permission CHECK (permission BETWEEN 0 AND 7)" + ) + op.execute( + "ALTER TABLE schedule_subscriptions ADD CONSTRAINT chk_schedule_subscription_notify_level CHECK (notify_level IN ('all', 'mentions', 'none'))" + ) + op.execute( + "ALTER TABLE schedule_subscriptions ADD CONSTRAINT chk_schedule_subscription_status CHECK (status IN ('active', 'paused', 'unsubscribed'))" + ) + op.create_foreign_key( + "fk_schedule_subscriptions_item_id", + "schedule_subscriptions", + "schedule_items", + ["item_id"], + ["id"], + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_schedule_subscriptions_subscriber_id", + "schedule_subscriptions", + "users", + ["subscriber_id"], + ["id"], + referent_schema="auth", + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_schedule_subscriptions_created_by", + "schedule_subscriptions", + "users", + ["created_by"], + ["id"], + referent_schema="auth", + ondelete="SET NULL", + ) + _enable_rls("schedule_subscriptions") + + op.create_table( + "todos", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("owner_id", sa.UUID(), nullable=False), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("description", sa.String(length=1000), nullable=True), + sa.Column("due_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("priority", sa.Integer(), nullable=False), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("created_by", sa.UUID(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_todos_owner_status_due", + "todos", + ["owner_id", "status", "due_at"], + unique=False, + ) + op.create_index( + "ix_todos_owner_created", "todos", ["owner_id", "created_at"], unique=False + ) + op.execute( + "ALTER TABLE todos ADD CONSTRAINT chk_todos_status CHECK (status IN ('pending', 'done', 'canceled'))" + ) + op.execute( + "ALTER TABLE todos ADD CONSTRAINT chk_todos_priority CHECK (priority BETWEEN 1 AND 4)" + ) + op.create_foreign_key( + "fk_todos_owner_id", + "todos", + "users", + ["owner_id"], + ["id"], + referent_schema="auth", + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_todos_created_by", + "todos", + "users", + ["created_by"], + ["id"], + referent_schema="auth", + ondelete="SET NULL", + ) + _enable_rls("todos") + + op.create_table( + "todo_sources", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("todo_id", sa.UUID(), nullable=False), + sa.Column("schedule_item_id", sa.UUID(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "todo_id", "schedule_item_id", name="uq_todo_sources_todo_schedule" + ), + ) + op.create_index("ix_todo_sources_todo", "todo_sources", ["todo_id"], unique=False) + op.create_index( + "ix_todo_sources_schedule_item", + "todo_sources", + ["schedule_item_id"], + unique=False, + ) + op.create_foreign_key( + "fk_todo_sources_todo_id", + "todo_sources", + "todos", + ["todo_id"], + ["id"], + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_todo_sources_schedule_item_id", + "todo_sources", + "schedule_items", + ["schedule_item_id"], + ["id"], + ondelete="CASCADE", + ) + _enable_rls("todo_sources") + + op.create_table( + "inbox_messages", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("recipient_id", sa.UUID(), nullable=False), + sa.Column("sender_id", sa.UUID(), nullable=True), + sa.Column("message_type", sa.String(length=20), nullable=False), + sa.Column("friendship_id", sa.UUID(), nullable=True), + sa.Column("schedule_item_id", sa.UUID(), nullable=True), + sa.Column("group_id", sa.UUID(), nullable=True), + sa.Column("content", sa.Text(), nullable=True), + sa.Column( + "is_read", sa.Boolean(), nullable=False, server_default=sa.text("false") + ), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("created_by", sa.UUID(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_inbox_messages_recipient_status_created", + "inbox_messages", + ["recipient_id", "status", "created_at"], + unique=False, + ) + op.execute( + "ALTER TABLE inbox_messages ADD CONSTRAINT chk_inbox_message_type CHECK (message_type IN ('friend_request', 'calendar', 'system', 'group'))" + ) + op.execute( + "ALTER TABLE inbox_messages ADD CONSTRAINT chk_inbox_message_status CHECK (status IN ('pending', 'accepted', 'rejected', 'dismissed'))" + ) + op.execute( + "ALTER TABLE inbox_messages ADD CONSTRAINT chk_inbox_message_sender CHECK ((message_type = 'system' AND sender_id IS NULL) OR (message_type <> 'system' AND sender_id IS NOT NULL))" + ) + op.execute( + "ALTER TABLE inbox_messages ADD CONSTRAINT chk_inbox_message_friendship CHECK ((message_type = 'friend_request' AND friendship_id IS NOT NULL) OR (message_type <> 'friend_request' AND friendship_id IS NULL))" + ) + op.execute( + "ALTER TABLE inbox_messages ADD CONSTRAINT chk_inbox_message_schedule_item CHECK ((message_type = 'calendar' AND schedule_item_id IS NOT NULL) OR (message_type <> 'calendar' AND schedule_item_id IS NULL))" + ) + op.execute( + "ALTER TABLE inbox_messages ADD CONSTRAINT chk_inbox_message_group CHECK ((message_type = 'group' AND group_id IS NOT NULL) OR (message_type <> 'group' AND group_id IS NULL))" + ) + op.execute( + "ALTER TABLE inbox_messages ADD CONSTRAINT chk_inbox_message_system_fields CHECK ((message_type = 'system' AND friendship_id IS NULL AND schedule_item_id IS NULL AND group_id IS NULL) OR (message_type <> 'system' AND (friendship_id IS NOT NULL OR schedule_item_id IS NOT NULL OR group_id IS NOT NULL)))" + ) + op.create_foreign_key( + "fk_inbox_messages_recipient_id", + "inbox_messages", + "users", + ["recipient_id"], + ["id"], + referent_schema="auth", + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_inbox_messages_sender_id", + "inbox_messages", + "users", + ["sender_id"], + ["id"], + referent_schema="auth", + ondelete="SET NULL", + ) + op.create_foreign_key( + "fk_inbox_messages_friendship_id", + "inbox_messages", + "friendships", + ["friendship_id"], + ["id"], + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_inbox_messages_schedule_item_id", + "inbox_messages", + "schedule_items", + ["schedule_item_id"], + ["id"], + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_inbox_messages_group_id", + "inbox_messages", + "groups", + ["group_id"], + ["id"], + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_inbox_messages_created_by", + "inbox_messages", + "users", + ["created_by"], + ["id"], + referent_schema="auth", + ondelete="SET NULL", + ) + _enable_rls("inbox_messages") + + +def downgrade() -> None: + _drop_rls("inbox_messages") + op.drop_constraint( + "fk_inbox_messages_created_by", "inbox_messages", type_="foreignkey" + ) + op.drop_constraint( + "fk_inbox_messages_group_id", "inbox_messages", type_="foreignkey" + ) + op.drop_constraint( + "fk_inbox_messages_schedule_item_id", "inbox_messages", type_="foreignkey" + ) + op.drop_constraint( + "fk_inbox_messages_friendship_id", "inbox_messages", type_="foreignkey" + ) + op.drop_constraint( + "fk_inbox_messages_sender_id", "inbox_messages", type_="foreignkey" + ) + op.drop_constraint( + "fk_inbox_messages_recipient_id", "inbox_messages", type_="foreignkey" + ) + op.drop_table("inbox_messages") + + _drop_rls("todo_sources") + op.drop_constraint( + "fk_todo_sources_schedule_item_id", "todo_sources", type_="foreignkey" + ) + op.drop_constraint("fk_todo_sources_todo_id", "todo_sources", type_="foreignkey") + op.drop_table("todo_sources") + + _drop_rls("todos") + op.drop_constraint("fk_todos_created_by", "todos", type_="foreignkey") + op.drop_constraint("fk_todos_owner_id", "todos", type_="foreignkey") + op.drop_table("todos") + + _drop_rls("schedule_subscriptions") + op.drop_constraint( + "fk_schedule_subscriptions_created_by", + "schedule_subscriptions", + type_="foreignkey", + ) + op.drop_constraint( + "fk_schedule_subscriptions_subscriber_id", + "schedule_subscriptions", + type_="foreignkey", + ) + op.drop_constraint( + "fk_schedule_subscriptions_item_id", + "schedule_subscriptions", + type_="foreignkey", + ) + op.drop_table("schedule_subscriptions") + + _drop_rls("schedule_items") + op.drop_constraint( + "fk_schedule_items_created_by", "schedule_items", type_="foreignkey" + ) + op.drop_constraint( + "fk_schedule_items_owner_id", "schedule_items", type_="foreignkey" + ) + op.drop_table("schedule_items") + + +def _enable_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + for action in ["select", "insert", "update", "delete"]: + op.execute( + f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" + ) + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + for role in ["anon", "authenticated"]: + op.execute( + f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)" + ) + op.execute( + f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)" + ) + + +def _drop_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + op.execute(f"DROP POLICY IF EXISTS {role}_delete_{table_name} ON {table_name}") + op.execute(f"DROP POLICY IF EXISTS {role}_update_{table_name} ON {table_name}") + op.execute(f"DROP POLICY IF EXISTS {role}_insert_{table_name} ON {table_name}") + op.execute(f"DROP POLICY IF EXISTS {role}_select_{table_name} ON {table_name}") + op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/alembic/versions/20260226_0005_hardening_indexes.py b/backend/alembic/versions/20260226_0005_hardening_indexes.py new file mode 100644 index 0000000..e603470 --- /dev/null +++ b/backend/alembic/versions/20260226_0005_hardening_indexes.py @@ -0,0 +1,29 @@ +"""initial schema part 5: hardening and partial indexes + +Revision ID: 202602260005 +Revises: 202602260004 +Create Date: 2026-02-26 20:14:00 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "202602260005" +down_revision: Union[str, Sequence[str], None] = "202602260004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + "CREATE INDEX ix_inbox_messages_pending_recent ON inbox_messages (recipient_id, created_at DESC) WHERE status = 'pending'" + ) + op.execute( + "CREATE INDEX ix_todos_pending_due ON todos (owner_id, due_at) WHERE status = 'pending'" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_todos_pending_due") + op.execute("DROP INDEX IF EXISTS ix_inbox_messages_pending_recent") diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py index bf94edd..6ac51d9 100644 --- a/backend/src/models/__init__.py +++ b/backend/src/models/__init__.py @@ -2,14 +2,34 @@ from __future__ import annotations from models.agent_chat_message import AgentChatMessage from models.agent_chat_session import AgentChatSession +from models.automation_jobs import AutomationJob +from models.group_members import GroupMember +from models.groups import Group +from models.inbox_messages import InboxMessage from models.llm import Llm from models.llm_factory import LlmFactory +from models.memories import Memory from models.profile import Profile +from models.schedule_items import ScheduleItem +from models.schedule_subscriptions import ScheduleSubscription +from models.todos import Todo +from models.todo_sources import TodoSource +from models.user_agents import UserAgent __all__ = [ "AgentChatMessage", "AgentChatSession", + "AutomationJob", + "GroupMember", + "Group", + "InboxMessage", "Llm", "LlmFactory", + "Memory", "Profile", + "ScheduleItem", + "ScheduleSubscription", + "Todo", + "TodoSource", + "UserAgent", ] diff --git a/backend/src/models/agent_chat_session.py b/backend/src/models/agent_chat_session.py index dbf974e..55ac364 100644 --- a/backend/src/models/agent_chat_session.py +++ b/backend/src/models/agent_chat_session.py @@ -8,7 +8,6 @@ from enum import Enum from sqlalchemy import ( DateTime, Enum as SqlEnum, - ForeignKey, Integer, Numeric, String, @@ -28,18 +27,32 @@ class AgentChatSessionStatus(str, Enum): FAILED = "failed" +class SessionType(str, Enum): + CHAT = "chat" + AUTOMATION = "automation" + + class AgentChatSession(TimestampMixin, SoftDeleteMixin, Base): __tablename__: str = "sessions" + __table_args__ = {"extend_existing": True} id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), - ForeignKey("auth.users.id", ondelete="CASCADE"), nullable=False, index=True, ) + session_type: Mapped[SessionType] = mapped_column( + String(20), + nullable=False, + default=SessionType.CHAT, + ) + job_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) title: Mapped[str | None] = mapped_column(String(255), nullable=True) status: Mapped[AgentChatSessionStatus] = mapped_column( SqlEnum( diff --git a/backend/src/models/automation_jobs.py b/backend/src/models/automation_jobs.py new file mode 100644 index 0000000..a6ebd42 --- /dev/null +++ b/backend/src/models/automation_jobs.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from enum import Enum + +from sqlalchemy import DateTime, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, SoftDeleteMixin, TimestampMixin + + +class AutomationJobStatus(str, Enum): + ACTIVE = "active" + DISABLED = "disabled" + + +class ScheduleType(str, Enum): + DAILY = "daily" + WEEKLY = "weekly" + + +class AutomationJob(TimestampMixin, SoftDeleteMixin, Base): + __tablename__: str = "automation_jobs" + __table_args__ = {"extend_existing": True} + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + owner_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + ) + title: Mapped[str] = mapped_column( + String(255), + nullable=False, + ) + prompt: Mapped[str] = mapped_column( + Text, + nullable=False, + ) + schedule_type: Mapped[ScheduleType] = mapped_column( + String(20), + nullable=False, + ) + run_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + ) + next_run_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + ) + timezone: Mapped[str] = mapped_column( + String(50), + nullable=False, + default="UTC", + ) + last_run_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + status: Mapped[AutomationJobStatus] = mapped_column( + String(20), + nullable=False, + default=AutomationJobStatus.ACTIVE, + ) + created_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) diff --git a/backend/src/models/friendships.py b/backend/src/models/friendships.py new file mode 100644 index 0000000..751618c --- /dev/null +++ b/backend/src/models/friendships.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import uuid +from enum import Enum + +from sqlalchemy import String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, SoftDeleteMixin, TimestampMixin + + +class FriendshipStatus(str, Enum): + PENDING = "pending" + ACCEPTED = "accepted" + BLOCKED = "blocked" + DECLINED = "declined" + CANCELED = "canceled" + + +class Friendship(TimestampMixin, SoftDeleteMixin, Base): + __tablename__: str = "friendships" + __table_args__ = {"extend_existing": True} + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_low_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + ) + user_high_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + ) + initiator_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) + status: Mapped[FriendshipStatus] = mapped_column( + String(20), + nullable=False, + default=FriendshipStatus.PENDING, + ) + requested_at: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) + accepted_at: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) + blocked_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) + created_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) + updated_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) diff --git a/backend/src/models/group_members.py b/backend/src/models/group_members.py new file mode 100644 index 0000000..07d7e8f --- /dev/null +++ b/backend/src/models/group_members.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import uuid +from enum import Enum + +from sqlalchemy import String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, SoftDeleteMixin, TimestampMixin + + +class GroupMemberRole(str, Enum): + OWNER = "owner" + ADMIN = "admin" + MEMBER = "member" + + +class GroupMemberSource(str, Enum): + INVITED = "invited" + JOINED = "joined" + + +class GroupMemberStatus(str, Enum): + ACTIVE = "active" + MUTED = "muted" + REMOVED = "removed" + + +class GroupMember(TimestampMixin, SoftDeleteMixin, Base): + __tablename__: str = "group_members" + __table_args__ = {"extend_existing": True} + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + group_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + ) + role: Mapped[GroupMemberRole] = mapped_column( + String(20), + nullable=False, + ) + join_source: Mapped[GroupMemberSource] = mapped_column( + String(20), + nullable=False, + ) + invited_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) + joined_at: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) + removed_at: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) + status: Mapped[GroupMemberStatus] = mapped_column( + String(20), + nullable=False, + default=GroupMemberStatus.ACTIVE, + ) + created_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) + updated_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) diff --git a/backend/src/models/groups.py b/backend/src/models/groups.py new file mode 100644 index 0000000..d54b607 --- /dev/null +++ b/backend/src/models/groups.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import uuid +from enum import Enum + +from sqlalchemy import String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, SoftDeleteMixin, TimestampMixin + + +class GroupStatus(str, Enum): + ACTIVE = "active" + ARCHIVED = "archived" + + +class Group(TimestampMixin, SoftDeleteMixin, Base): + __tablename__: str = "groups" + __table_args__ = {"extend_existing": True} + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + name: Mapped[str] = mapped_column( + String(100), + nullable=False, + ) + description: Mapped[str | None] = mapped_column( + Text, + nullable=True, + ) + owner_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + ) + status: Mapped[GroupStatus] = mapped_column( + String(20), + nullable=False, + default=GroupStatus.ACTIVE, + ) + created_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) + updated_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) diff --git a/backend/src/models/inbox_messages.py b/backend/src/models/inbox_messages.py new file mode 100644 index 0000000..6d9c64d --- /dev/null +++ b/backend/src/models/inbox_messages.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import uuid +from enum import Enum + +from sqlalchemy import String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, TimestampMixin + + +class InboxMessageType(str, Enum): + FRIEND_REQUEST = "friend_request" + CALENDAR = "calendar" + SYSTEM = "system" + GROUP = "group" + + +class InboxMessageStatus(str, Enum): + PENDING = "pending" + ACCEPTED = "accepted" + REJECTED = "rejected" + DISMISSED = "dismissed" + + +class InboxMessage(TimestampMixin, Base): + __tablename__: str = "inbox_messages" + __table_args__ = {"extend_existing": True} + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + recipient_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + ) + sender_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) + message_type: Mapped[InboxMessageType] = mapped_column( + String(20), + nullable=False, + ) + friendship_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) + schedule_item_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) + group_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) + content: Mapped[str | None] = mapped_column( + Text, + nullable=True, + ) + is_read: Mapped[bool] = mapped_column( + String(10), + nullable=False, + default=False, + ) + status: Mapped[InboxMessageStatus] = mapped_column( + String(20), + nullable=False, + default=InboxMessageStatus.PENDING, + ) + created_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) diff --git a/backend/src/models/memories.py b/backend/src/models/memories.py new file mode 100644 index 0000000..c075085 --- /dev/null +++ b/backend/src/models/memories.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import uuid +from enum import Enum + +from sqlalchemy import String +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, TimestampMixin + + +class MemoryType(str, Enum): + USER = "user" + WORK = "work" + + +class MemorySource(str, Enum): + MANUAL = "manual" + AGENT = "agent" + IMPORTED = "imported" + + +class MemoryStatus(str, Enum): + ACTIVE = "active" + DISABLED = "disabled" + + +class Memory(TimestampMixin, Base): + __tablename__: str = "memories" + __table_args__ = {"extend_existing": True} + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + owner_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + ) + agent_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) + memory_type: Mapped[MemoryType] = mapped_column( + String(20), + nullable=False, + ) + title: Mapped[str | None] = mapped_column(String(255), nullable=True) + content: Mapped[dict] = mapped_column( + JSONB, + nullable=False, + ) + source: Mapped[MemorySource] = mapped_column( + String(20), + nullable=False, + ) + status: Mapped[MemoryStatus] = mapped_column( + String(20), + nullable=False, + default=MemoryStatus.ACTIVE, + ) diff --git a/backend/src/models/profile.py b/backend/src/models/profile.py index d903c20..917a6af 100644 --- a/backend/src/models/profile.py +++ b/backend/src/models/profile.py @@ -2,8 +2,8 @@ from __future__ import annotations import uuid -from sqlalchemy import ForeignKey, String, Text -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy import String, Text +from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import Mapped, mapped_column from core.db.base import Base, SoftDeleteMixin, TimestampMixin @@ -18,10 +18,10 @@ class Profile(TimestampMixin, SoftDeleteMixin, Base): """ __tablename__: str = "profiles" + __table_args__ = {"extend_existing": True} id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), - ForeignKey("auth.users.id", ondelete="CASCADE"), primary_key=True, ) username: Mapped[str] = mapped_column( @@ -37,3 +37,8 @@ class Profile(TimestampMixin, SoftDeleteMixin, Base): String(200), nullable=True, ) + settings: Mapped[dict] = mapped_column( + JSONB, + nullable=False, + server_default="{}", + ) diff --git a/backend/src/models/schedule_items.py b/backend/src/models/schedule_items.py new file mode 100644 index 0000000..6d2eb81 --- /dev/null +++ b/backend/src/models/schedule_items.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from enum import Enum + +from sqlalchemy import DateTime, String, Text +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, SoftDeleteMixin, TimestampMixin + + +class ScheduleItemStatus(str, Enum): + ACTIVE = "active" + COMPLETED = "completed" + CANCELED = "canceled" + ARCHIVED = "archived" + + +class ScheduleItemSourceType(str, Enum): + MANUAL = "manual" + IMPORTED = "imported" + AGENT_GENERATED = "agent_generated" + + +class ScheduleItem(TimestampMixin, SoftDeleteMixin, Base): + __tablename__: str = "schedule_items" + __table_args__ = {"extend_existing": True} + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + owner_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + ) + title: Mapped[str] = mapped_column( + String(255), + nullable=False, + ) + description: Mapped[str | None] = mapped_column( + Text, + nullable=True, + ) + start_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + ) + end_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + timezone: Mapped[str] = mapped_column( + String(50), + nullable=False, + default="UTC", + ) + extra_metadata: Mapped[dict] = mapped_column( + "metadata", + JSONB, + nullable=False, + server_default="{}", + ) + recurrence_rule: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + ) + source_type: Mapped[ScheduleItemSourceType] = mapped_column( + String(20), + nullable=False, + default=ScheduleItemSourceType.MANUAL, + ) + status: Mapped[ScheduleItemStatus] = mapped_column( + String(20), + nullable=False, + default=ScheduleItemStatus.ACTIVE, + ) + created_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) diff --git a/backend/src/models/schedule_subscriptions.py b/backend/src/models/schedule_subscriptions.py new file mode 100644 index 0000000..e9072ca --- /dev/null +++ b/backend/src/models/schedule_subscriptions.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import uuid +from enum import Enum + +from sqlalchemy import Integer, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, TimestampMixin + + +class SubscriptionStatus(str, Enum): + ACTIVE = "active" + PAUSED = "paused" + UNSUBSCRIBED = "unsubscribed" + + +class NotifyLevel(str, Enum): + ALL = "all" + MENTIONS = "mentions" + NONE = "none" + + +class ScheduleSubscription(TimestampMixin, Base): + __tablename__: str = "schedule_subscriptions" + __table_args__ = {"extend_existing": True} + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + item_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + ) + subscriber_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + ) + permission: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=1, + ) + notify_level: Mapped[NotifyLevel] = mapped_column( + String(20), + nullable=False, + default=NotifyLevel.ALL, + ) + status: Mapped[SubscriptionStatus] = mapped_column( + String(20), + nullable=False, + default=SubscriptionStatus.ACTIVE, + ) + created_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) diff --git a/backend/src/models/todo_sources.py b/backend/src/models/todo_sources.py new file mode 100644 index 0000000..73398b9 --- /dev/null +++ b/backend/src/models/todo_sources.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, TimestampMixin + + +class TodoSource(TimestampMixin, Base): + __tablename__: str = "todo_sources" + __table_args__ = {"extend_existing": True} + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + todo_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + ) + schedule_item_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + ) diff --git a/backend/src/models/todos.py b/backend/src/models/todos.py new file mode 100644 index 0000000..65c96eb --- /dev/null +++ b/backend/src/models/todos.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from enum import Enum + +from sqlalchemy import DateTime, Integer, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, SoftDeleteMixin, TimestampMixin + + +class TodoStatus(str, Enum): + PENDING = "pending" + DONE = "done" + CANCELED = "canceled" + + +class TodoPriority(int, Enum): + IMPORTANT_URGENT = 1 + IMPORTANT_NOT_URGENT = 2 + NOT_IMPORTANT_URGENT = 3 + NOT_IMPORTANT_NOT_URGENT = 4 + + +class Todo(TimestampMixin, SoftDeleteMixin, Base): + __tablename__: str = "todos" + __table_args__ = {"extend_existing": True} + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + owner_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + ) + title: Mapped[str] = mapped_column( + String(255), + nullable=False, + ) + description: Mapped[str | None] = mapped_column( + String(1000), + nullable=True, + ) + due_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + priority: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=TodoPriority.NOT_IMPORTANT_NOT_URGENT, + ) + completed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + status: Mapped[TodoStatus] = mapped_column( + String(20), + nullable=False, + default=TodoStatus.PENDING, + ) + created_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) diff --git a/backend/src/models/user_agents.py b/backend/src/models/user_agents.py new file mode 100644 index 0000000..9015959 --- /dev/null +++ b/backend/src/models/user_agents.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import uuid +from enum import Enum + +from sqlalchemy import String +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, SoftDeleteMixin, TimestampMixin + + +class UserAgentStatus(str, Enum): + ACTIVE = "active" + PAUSED = "paused" + MIGRATING = "migrating" + + +class AgentType(str, Enum): + INTENT_RECOGNITION = "INTENT_RECOGNITION" + TASK_EXECUTION = "TASK_EXECUTION" + RESULT_REPORTING = "RESULT_REPORTING" + + +class UserAgent(TimestampMixin, SoftDeleteMixin, Base): + __tablename__: str = "user_agents" + __table_args__ = {"extend_existing": True} + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + unique=True, + ) + llm_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + ) + agent_type: Mapped[AgentType] = mapped_column( + String(20), + nullable=False, + ) + config: Mapped[dict] = mapped_column( + JSONB, + nullable=False, + server_default="{}", + ) + status: Mapped[UserAgentStatus] = mapped_column( + String(20), + nullable=False, + default=UserAgentStatus.ACTIVE, + ) + created_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) + updated_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + nullable=True, + ) diff --git a/backend/tests/unit/database/test_agent_chat_migration_contract.py b/backend/tests/unit/database/test_agent_chat_migration_contract.py index 83b587d..7eaa4c6 100644 --- a/backend/tests/unit/database/test_agent_chat_migration_contract.py +++ b/backend/tests/unit/database/test_agent_chat_migration_contract.py @@ -3,15 +3,25 @@ from __future__ import annotations from pathlib import Path -def test_agent_chat_migration_exists_and_creates_expected_tables() -> None: +def test_initial_migration_exists_and_creates_expected_tables() -> None: versions_dir = Path(__file__).resolve().parents[3] / "alembic" / "versions" - migration = versions_dir / "20260226_create_agent_chat_core_tables.py" + migration_files = sorted(versions_dir.glob("20260226_*.py")) + assert len(migration_files) == 5, "split initial migrations should exist" - assert migration.exists() + content = "\n".join(m.read_text(encoding="utf-8") for m in migration_files) - content = migration.read_text(encoding="utf-8") - assert 'create_table(\n "llm_factory"' in content - assert 'create_table(\n "llms"' in content - assert 'create_table(\n "sessions"' in content - assert 'create_table(\n "messages"' in content - assert "tool_calls" not in content + # New tables from social data model redesign + assert "create_table(" in content and "automation_jobs" in content + assert "create_table(" in content and "user_agents" in content + assert "create_table(" in content and "memories" in content + assert "create_table(" in content and "friendships" in content + assert "create_table(" in content and "groups" in content + assert "create_table(" in content and "group_members" in content + assert "create_table(" in content and "schedule_items" in content + assert "create_table(" in content and "schedule_subscriptions" in content + assert "create_table(" in content and "inbox_messages" in content + assert "create_table(" in content and "todos" in content + assert "create_table(" in content and "todo_sources" in content + assert "create_table(" in content and "profiles" in content + assert "create_table(" in content and "sessions" in content + assert "create_table(" in content and "messages" in content diff --git a/backend/tests/unit/database/test_profile_migration_contract.py b/backend/tests/unit/database/test_profile_migration_contract.py index 890a339..6399676 100644 --- a/backend/tests/unit/database/test_profile_migration_contract.py +++ b/backend/tests/unit/database/test_profile_migration_contract.py @@ -3,14 +3,13 @@ from __future__ import annotations from pathlib import Path -def test_drop_display_name_migration_exists_and_uses_username_metadata() -> None: +def test_profiles_has_settings_column() -> None: + """Verify profiles and auth trigger are defined in split migrations.""" versions_dir = Path(__file__).resolve().parents[3] / "alembic" / "versions" - migration = ( - versions_dir / "20260224_drop_profile_display_name_and_trigger_username.py" - ) + migration_files = sorted(versions_dir.glob("20260226_*.py")) + content = "\n".join(m.read_text(encoding="utf-8") for m in migration_files) - assert migration.exists() - - content = migration.read_text(encoding="utf-8") - assert "DROP COLUMN" in content and "display_name" in content - assert "raw_user_meta_data->>'username'" in content + assert 'create_table(\n "profiles"' in content + assert '"settings"' in content + assert "create_profile_for_new_user" in content + assert "on_auth_user_created" in content