feat: add invite rewards and redeem codes

This commit is contained in:
zl-q
2026-05-21 16:26:58 +08:00
parent d712645754
commit 673f8fed30
67 changed files with 3813 additions and 265 deletions
@@ -27,8 +27,18 @@ def upgrade() -> None:
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(
"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"),
@@ -40,10 +50,25 @@ def upgrade() -> None:
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(
"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.ForeignKeyConstraint(["factory_id"], ["llm_factory.id"], name="fk_llms_factory_id", ondelete="RESTRICT"),
sa.ForeignKeyConstraint(
["factory_id"],
["llm_factory.id"],
name="fk_llms_factory_id",
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("model_code"),
)
@@ -55,10 +80,27 @@ def upgrade() -> None:
sa.Column("agent_type", sa.String(length=20), nullable=False),
sa.Column("llm_id", sa.UUID(), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("config", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), 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.ForeignKeyConstraint(["llm_id"], ["llms.id"], name="fk_system_agents_llm_id", ondelete="RESTRICT"),
sa.Column(
"config",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
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.ForeignKeyConstraint(
["llm_id"], ["llms.id"], name="fk_system_agents_llm_id", ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("agent_type"),
)
_enable_service_only_rls("system_agents")
@@ -82,17 +124,29 @@ def downgrade() -> None:
def _enable_service_only_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"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)")
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_service_only_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"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
)
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")
@@ -30,7 +30,9 @@ def upgrade() -> None:
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users")
op.execute("DROP FUNCTION IF EXISTS public.initialize_profile_and_invite_code_on_signup()")
op.execute(
"DROP FUNCTION IF EXISTS public.initialize_profile_and_invite_code_on_signup()"
)
op.execute("DROP FUNCTION IF EXISTS public.generate_invite_code()")
for table_name in [
@@ -61,18 +63,37 @@ def _create_profiles() -> None:
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=sa.text("'{}'::jsonb"), nullable=False),
sa.Column(
"settings",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.Column("referred_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(
"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.CheckConstraint("char_length(username) >= 1", name="ck_profiles_username_non_empty"),
sa.CheckConstraint(
"char_length(username) >= 1", name="ck_profiles_username_non_empty"
),
sa.ForeignKeyConstraint(["id"], ["auth.users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["referred_by"], ["profiles.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_profiles_username", "profiles", ["username"])
op.create_index("ix_profiles_settings_gin", "profiles", ["settings"], postgresql_using="gin")
op.create_index(
"ix_profiles_settings_gin", "profiles", ["settings"], postgresql_using="gin"
)
op.create_index("ix_profiles_referred_by", "profiles", ["referred_by"])
_enable_service_only_rls("profiles")
@@ -86,24 +107,60 @@ def _create_chat_tables() -> None:
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(12, 6), server_default=sa.text("0"), nullable=False),
sa.Column("state_snapshot", 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(
"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(12, 6), server_default=sa.text("0"), nullable=False
),
sa.Column(
"state_snapshot", 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.CheckConstraint("session_type in ('chat', 'automation')", name="ck_sessions_session_type"),
sa.CheckConstraint("status in ('pending', 'running', 'completed', 'failed')", name="ck_sessions_status"),
sa.CheckConstraint("message_count >= 0", name="ck_sessions_message_count_non_negative"),
sa.CheckConstraint("total_tokens >= 0", name="ck_sessions_total_tokens_non_negative"),
sa.CheckConstraint("total_cost >= 0", name="ck_sessions_total_cost_non_negative"),
sa.CheckConstraint(
"session_type in ('chat', 'automation')", name="ck_sessions_session_type"
),
sa.CheckConstraint(
"status in ('pending', 'running', 'completed', 'failed')",
name="ck_sessions_status",
),
sa.CheckConstraint(
"message_count >= 0", name="ck_sessions_message_count_non_negative"
),
sa.CheckConstraint(
"total_tokens >= 0", name="ck_sessions_total_tokens_non_negative"
),
sa.CheckConstraint(
"total_cost >= 0", name="ck_sessions_total_cost_non_negative"
),
sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
op.create_index("ix_sessions_user_activity", "sessions", ["user_id", "last_activity_at"])
op.create_index(
"ix_sessions_user_activity", "sessions", ["user_id", "last_activity_at"]
)
_enable_service_only_rls("sessions")
op.create_table(
@@ -115,27 +172,61 @@ def _create_chat_tables() -> None:
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(), server_default=sa.text("0"), nullable=False),
sa.Column("output_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("cost", sa.Numeric(12, 6), server_default=sa.text("0"), nullable=False),
sa.Column(
"input_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False
),
sa.Column(
"output_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False
),
sa.Column(
"cost", sa.Numeric(12, 6), server_default=sa.text("0"), nullable=False
),
sa.Column("latency_ms", sa.Integer(), nullable=True),
sa.Column("visibility_mask", sa.BigInteger(), server_default=sa.text("0"), nullable=False),
sa.Column(
"visibility_mask",
sa.BigInteger(),
server_default=sa.text("0"),
nullable=False,
),
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(
"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.CheckConstraint("seq > 0", name="ck_messages_seq_positive"),
sa.CheckConstraint("role in ('user', 'assistant', 'system', 'tool')", name="ck_messages_role"),
sa.CheckConstraint("input_tokens >= 0", name="ck_messages_input_tokens_non_negative"),
sa.CheckConstraint("output_tokens >= 0", name="ck_messages_output_tokens_non_negative"),
sa.CheckConstraint(
"role in ('user', 'assistant', 'system', 'tool')", name="ck_messages_role"
),
sa.CheckConstraint(
"input_tokens >= 0", name="ck_messages_input_tokens_non_negative"
),
sa.CheckConstraint(
"output_tokens >= 0", name="ck_messages_output_tokens_non_negative"
),
sa.CheckConstraint("cost >= 0", name="ck_messages_cost_non_negative"),
sa.CheckConstraint("latency_ms is null or latency_ms >= 0", name="ck_messages_latency_non_negative"),
sa.CheckConstraint(
"latency_ms is null or latency_ms >= 0",
name="ck_messages_latency_non_negative",
),
sa.ForeignKeyConstraint(["session_id"], ["sessions.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("session_id", "seq", name="uq_messages_session_seq"),
)
op.create_index("ix_messages_session_id", "messages", ["session_id"])
op.create_index("ix_messages_session_seq_visibility", "messages", ["session_id", "seq", "visibility_mask"])
op.create_index(
"ix_messages_session_seq_visibility",
"messages",
["session_id", "seq", "visibility_mask"],
)
_enable_service_only_rls("messages")
@@ -143,18 +234,53 @@ def _create_points_tables() -> None:
op.create_table(
"user_points",
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("balance", sa.BigInteger(), server_default=sa.text("0"), nullable=False),
sa.Column("frozen_balance", sa.BigInteger(), server_default=sa.text("0"), nullable=False),
sa.Column("lifetime_earned", sa.BigInteger(), server_default=sa.text("0"), nullable=False),
sa.Column("lifetime_spent", sa.BigInteger(), server_default=sa.text("0"), nullable=False),
sa.Column(
"balance", sa.BigInteger(), server_default=sa.text("0"), nullable=False
),
sa.Column(
"frozen_balance",
sa.BigInteger(),
server_default=sa.text("0"),
nullable=False,
),
sa.Column(
"lifetime_earned",
sa.BigInteger(),
server_default=sa.text("0"),
nullable=False,
),
sa.Column(
"lifetime_spent",
sa.BigInteger(),
server_default=sa.text("0"),
nullable=False,
),
sa.Column("version", sa.Integer(), 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(
"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.CheckConstraint("balance >= 0", name="ck_user_points_balance_non_negative"),
sa.CheckConstraint("frozen_balance >= 0", name="ck_user_points_frozen_balance_non_negative"),
sa.CheckConstraint("lifetime_earned >= 0", name="ck_user_points_lifetime_earned_non_negative"),
sa.CheckConstraint("lifetime_spent >= 0", name="ck_user_points_lifetime_spent_non_negative"),
sa.CheckConstraint("frozen_balance <= balance", name="ck_user_points_frozen_le_balance"),
sa.CheckConstraint(
"frozen_balance >= 0", name="ck_user_points_frozen_balance_non_negative"
),
sa.CheckConstraint(
"lifetime_earned >= 0", name="ck_user_points_lifetime_earned_non_negative"
),
sa.CheckConstraint(
"lifetime_spent >= 0", name="ck_user_points_lifetime_spent_non_negative"
),
sa.CheckConstraint(
"frozen_balance <= balance", name="ck_user_points_frozen_le_balance"
),
sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("user_id"),
)
@@ -172,30 +298,89 @@ def _create_points_tables() -> None:
sa.Column("biz_id", sa.UUID(), nullable=True),
sa.Column("event_id", sa.String(length=64), nullable=False),
sa.Column("operator_id", sa.UUID(), nullable=True),
sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), 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(
"metadata",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
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.CheckConstraint("amount > 0", name="ck_points_ledger_amount_positive"),
sa.CheckConstraint("direction in (1, -1)", name="ck_points_ledger_direction_valid"),
sa.CheckConstraint("balance_after >= 0", name="ck_points_ledger_balance_after_non_negative"),
sa.CheckConstraint("change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')", name="ck_points_ledger_change_type"),
sa.CheckConstraint("biz_type is null or biz_type in ('chat', 'payment')", name="ck_points_ledger_biz_type"),
sa.CheckConstraint("((change_type in ('register', 'adjust') and biz_type is null and biz_id is null) or (change_type = 'consume' and biz_type = 'chat' and biz_id is not null) or (change_type in ('purchase', 'refund') and biz_type = 'payment' and biz_id is not null))", name="ck_points_ledger_biz_binding"),
sa.CheckConstraint("((change_type in ('register', 'purchase') and direction = 1) or (change_type in ('consume', 'refund') and direction = -1) or (change_type = 'adjust' and direction in (1, -1)))", name="ck_points_ledger_direction_by_change_type"),
sa.CheckConstraint("jsonb_typeof(metadata) = 'object'", name="ck_points_ledger_metadata_object"),
sa.CheckConstraint("metadata->>'schema_version' = '1' and metadata->>'operator_type' in ('user', 'system', 'admin') and coalesce(metadata->>'run_id', '') <> '' and (not (metadata ? 'ext') or jsonb_typeof(metadata->'ext') = 'object')", name="ck_points_ledger_metadata_common"),
sa.CheckConstraint("(change_type <> 'register' or not (metadata ? 'charge'))", name="ck_points_ledger_metadata_register_shape"),
sa.CheckConstraint("(change_type <> 'consume' or ((metadata ? 'charge') and jsonb_typeof(metadata->'charge') = 'object' and (metadata->'charge' ? 'message_id') and (metadata->'charge' ? 'message_seq') and (metadata->'charge' ? 'model_code') and (metadata->'charge' ? 'input_tokens') and (metadata->'charge' ? 'output_tokens') and (metadata->'charge' ? 'cost')))", name="ck_points_ledger_metadata_consume_shape"),
sa.CheckConstraint("(change_type <> 'adjust' or ((metadata ? 'ext') and (metadata->'ext' ? 'reason') and coalesce(metadata #>> '{ext,reason}', '') <> ''))", name="ck_points_ledger_metadata_adjust_shape"),
sa.CheckConstraint("(change_type not in ('purchase', 'refund') or ((metadata ? 'ext') and (metadata->'ext' ? 'source') and (metadata->'ext' ? 'platform') and (metadata->'ext' ? 'product_code') and (metadata->'ext' ? 'transaction_id') and coalesce(metadata #>> '{ext,source}', '') <> '' and coalesce(metadata #>> '{ext,platform}', '') <> '' and coalesce(metadata #>> '{ext,product_code}', '') <> '' and coalesce(metadata #>> '{ext,transaction_id}', '') <> ''))", name="ck_points_ledger_metadata_payment_shape"),
sa.CheckConstraint("(change_type <> 'refund' or ((metadata ? 'ext') and (metadata->'ext' ? 'original_event_id') and coalesce(metadata #>> '{ext,original_event_id}', '') <> ''))", name="ck_points_ledger_metadata_refund_shape"),
sa.ForeignKeyConstraint(["operator_id"], ["auth.users.id"], ondelete="SET NULL"),
sa.CheckConstraint(
"direction in (1, -1)", name="ck_points_ledger_direction_valid"
),
sa.CheckConstraint(
"balance_after >= 0", name="ck_points_ledger_balance_after_non_negative"
),
sa.CheckConstraint(
"change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')",
name="ck_points_ledger_change_type",
),
sa.CheckConstraint(
"biz_type is null or biz_type in ('chat', 'payment')",
name="ck_points_ledger_biz_type",
),
sa.CheckConstraint(
"((change_type in ('register', 'adjust') and biz_type is null and biz_id is null) or (change_type = 'consume' and biz_type = 'chat' and biz_id is not null) or (change_type in ('purchase', 'refund') and biz_type = 'payment' and biz_id is not null))",
name="ck_points_ledger_biz_binding",
),
sa.CheckConstraint(
"((change_type in ('register', 'purchase') and direction = 1) or (change_type in ('consume', 'refund') and direction = -1) or (change_type = 'adjust' and direction in (1, -1)))",
name="ck_points_ledger_direction_by_change_type",
),
sa.CheckConstraint(
"jsonb_typeof(metadata) = 'object'", name="ck_points_ledger_metadata_object"
),
sa.CheckConstraint(
"metadata->>'schema_version' = '1' and metadata->>'operator_type' in ('user', 'system', 'admin') and coalesce(metadata->>'run_id', '') <> '' and (not (metadata ? 'ext') or jsonb_typeof(metadata->'ext') = 'object')",
name="ck_points_ledger_metadata_common",
),
sa.CheckConstraint(
"(change_type <> 'register' or not (metadata ? 'charge'))",
name="ck_points_ledger_metadata_register_shape",
),
sa.CheckConstraint(
"(change_type <> 'consume' or ((metadata ? 'charge') and jsonb_typeof(metadata->'charge') = 'object' and (metadata->'charge' ? 'message_id') and (metadata->'charge' ? 'message_seq') and (metadata->'charge' ? 'model_code') and (metadata->'charge' ? 'input_tokens') and (metadata->'charge' ? 'output_tokens') and (metadata->'charge' ? 'cost')))",
name="ck_points_ledger_metadata_consume_shape",
),
sa.CheckConstraint(
"(change_type <> 'adjust' or ((metadata ? 'ext') and (metadata->'ext' ? 'reason') and coalesce(metadata #>> '{ext,reason}', '') <> ''))",
name="ck_points_ledger_metadata_adjust_shape",
),
sa.CheckConstraint(
"(change_type not in ('purchase', 'refund') or ((metadata ? 'ext') and (metadata->'ext' ? 'source') and (metadata->'ext' ? 'platform') and (metadata->'ext' ? 'product_code') and (metadata->'ext' ? 'transaction_id') and coalesce(metadata #>> '{ext,source}', '') <> '' and coalesce(metadata #>> '{ext,platform}', '') <> '' and coalesce(metadata #>> '{ext,product_code}', '') <> '' and coalesce(metadata #>> '{ext,transaction_id}', '') <> ''))",
name="ck_points_ledger_metadata_payment_shape",
),
sa.CheckConstraint(
"(change_type <> 'refund' or ((metadata ? 'ext') and (metadata->'ext' ? 'original_event_id') and coalesce(metadata #>> '{ext,original_event_id}', '') <> ''))",
name="ck_points_ledger_metadata_refund_shape",
),
sa.ForeignKeyConstraint(
["operator_id"], ["auth.users.id"], ondelete="SET NULL"
),
sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "event_id", name="uq_points_ledger_user_event"),
)
op.create_index("ix_points_ledger_user_created_at", "points_ledger", ["user_id", sa.text("created_at DESC")])
op.create_index("ix_points_ledger_biz_type_biz_id", "points_ledger", ["biz_type", "biz_id"])
op.create_index(
"ix_points_ledger_user_created_at",
"points_ledger",
["user_id", sa.text("created_at DESC")],
)
op.create_index(
"ix_points_ledger_biz_type_biz_id", "points_ledger", ["biz_type", "biz_id"]
)
_enable_service_only_rls("points_ledger")
op.create_table(
@@ -213,24 +398,71 @@ def _create_points_tables() -> None:
sa.Column("billed_to", sa.String(length=16), nullable=False),
sa.Column("run_id", sa.String(length=128), nullable=True),
sa.Column("request_id", sa.String(length=128), nullable=True),
sa.Column("input_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("output_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("cost", sa.Numeric(12, 6), server_default=sa.text("0"), nullable=False),
sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), 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.CheckConstraint("amount >= 0", name="ck_points_audit_ledger_amount_non_negative"),
sa.CheckConstraint("direction in (1, 0, -1)", name="ck_points_audit_ledger_direction_valid"),
sa.CheckConstraint("balance_after >= 0", name="ck_points_audit_ledger_balance_after_non_negative"),
sa.CheckConstraint("change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')", name="ck_points_audit_ledger_change_type"),
sa.CheckConstraint("biz_type is null or biz_type in ('chat', 'payment')", name="ck_points_audit_ledger_biz_type"),
sa.CheckConstraint("billed_to in ('user', 'platform')", name="ck_points_audit_ledger_billed_to"),
sa.CheckConstraint("jsonb_typeof(metadata) = 'object'", name="ck_points_audit_ledger_metadata_object"),
sa.Column(
"input_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False
),
sa.Column(
"output_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False
),
sa.Column(
"cost", sa.Numeric(12, 6), server_default=sa.text("0"), nullable=False
),
sa.Column(
"metadata",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
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.CheckConstraint(
"amount >= 0", name="ck_points_audit_ledger_amount_non_negative"
),
sa.CheckConstraint(
"direction in (1, 0, -1)", name="ck_points_audit_ledger_direction_valid"
),
sa.CheckConstraint(
"balance_after >= 0",
name="ck_points_audit_ledger_balance_after_non_negative",
),
sa.CheckConstraint(
"change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')",
name="ck_points_audit_ledger_change_type",
),
sa.CheckConstraint(
"biz_type is null or biz_type in ('chat', 'payment')",
name="ck_points_audit_ledger_biz_type",
),
sa.CheckConstraint(
"billed_to in ('user', 'platform')", name="ck_points_audit_ledger_billed_to"
),
sa.CheckConstraint(
"jsonb_typeof(metadata) = 'object'",
name="ck_points_audit_ledger_metadata_object",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("event_id", name="uq_points_audit_ledger_event_id"),
)
op.create_index("ix_points_audit_ledger_user_id_created_at", "points_audit_ledger", ["user_id_snapshot", sa.text("created_at DESC")])
op.create_index("ix_points_audit_ledger_change_type_created_at", "points_audit_ledger", ["change_type", sa.text("created_at DESC")])
op.create_index(
"ix_points_audit_ledger_user_id_created_at",
"points_audit_ledger",
["user_id_snapshot", sa.text("created_at DESC")],
)
op.create_index(
"ix_points_audit_ledger_change_type_created_at",
"points_audit_ledger",
["change_type", sa.text("created_at DESC")],
)
_enable_service_only_rls("points_audit_ledger")
op.create_table(
@@ -241,12 +473,29 @@ def _create_points_tables() -> None:
sa.Column("first_user_id_snapshot", sa.UUID(), nullable=True),
sa.Column("balance_snapshot", sa.BigInteger(), nullable=True),
sa.Column("grant_event_id", sa.String(length=64), nullable=False),
sa.Column("has_purchased_starter_pack", sa.Boolean(), server_default=sa.text("false"), 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(
"has_purchased_starter_pack",
sa.Boolean(),
server_default=sa.text("false"),
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("email_hash", name="uq_register_bonus_claims_email_hash"),
sa.UniqueConstraint("grant_event_id", name="uq_register_bonus_claims_grant_event_id"),
sa.UniqueConstraint(
"grant_event_id", name="uq_register_bonus_claims_grant_event_id"
),
)
_enable_service_only_rls("register_bonus_claims")
@@ -269,7 +518,9 @@ def _create_invite_codes() -> None:
"""
)
op.execute("CREATE INDEX ix_invite_codes_owner_id ON invite_codes(owner_id)")
op.execute("CREATE INDEX ix_invite_codes_code ON invite_codes(code) WHERE status = 'active'")
op.execute(
"CREATE INDEX ix_invite_codes_code ON invite_codes(code) WHERE status = 'active'"
)
_enable_service_only_rls("invite_codes")
@@ -370,23 +621,37 @@ def _create_signup_helpers() -> None:
"""
)
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.initialize_profile_and_invite_code_on_signup()")
op.execute(
"CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.initialize_profile_and_invite_code_on_signup()"
)
def _enable_service_only_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"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)")
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_service_only_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"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
)
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")
@@ -23,29 +23,90 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"notifications",
sa.Column("id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("type", sa.String(length=32), server_default=sa.text("'system'"), nullable=False),
sa.Column("source", sa.String(length=32), server_default=sa.text("'manual'"), nullable=False),
sa.Column(
"id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False
),
sa.Column(
"type",
sa.String(length=32),
server_default=sa.text("'system'"),
nullable=False,
),
sa.Column(
"source",
sa.String(length=32),
server_default=sa.text("'manual'"),
nullable=False,
),
sa.Column("source_key", sa.String(length=128), nullable=True),
sa.Column("source_version", sa.Integer(), nullable=True),
sa.Column("content_hash", sa.String(length=64), nullable=True),
sa.Column("title", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("body", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column("status", sa.String(length=16), server_default=sa.text("'published'"), nullable=False),
sa.Column("target_mode", sa.String(length=32), server_default=sa.text("'all_users'"), nullable=False),
sa.Column(
"title",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.Column(
"body",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.Column(
"payload",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.Column(
"status",
sa.String(length=16),
server_default=sa.text("'published'"),
nullable=False,
),
sa.Column(
"target_mode",
sa.String(length=32),
server_default=sa.text("'all_users'"),
nullable=False,
),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), 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(
"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.CheckConstraint("status IN ('draft', 'published', 'revoked')", name="ck_notifications_status"),
sa.CheckConstraint("target_mode IN ('new_users', 'exist_users', 'all_users', 'user_ids')", name="ck_notifications_target_mode"),
sa.CheckConstraint("jsonb_typeof(payload) = 'object'", name="ck_notifications_payload_object"),
sa.CheckConstraint(
"status IN ('draft', 'published', 'revoked')",
name="ck_notifications_status",
),
sa.CheckConstraint(
"target_mode IN ('new_users', 'exist_users', 'all_users', 'user_ids')",
name="ck_notifications_target_mode",
),
sa.CheckConstraint(
"jsonb_typeof(payload) = 'object'", name="ck_notifications_payload_object"
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_notifications_status_created_at", "notifications", ["status", sa.text("created_at DESC")])
op.create_index("ix_notifications_published_at", "notifications", [sa.text("published_at DESC")])
op.create_index(
"ix_notifications_status_created_at",
"notifications",
["status", sa.text("created_at DESC")],
)
op.create_index(
"ix_notifications_published_at", "notifications", [sa.text("published_at DESC")]
)
op.create_index(
"uq_notifications_source_source_key",
"notifications",
@@ -57,20 +118,46 @@ def upgrade() -> None:
op.create_table(
"user_notifications",
sa.Column("id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column(
"id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False
),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("notification_id", sa.UUID(), nullable=False),
sa.Column("is_read", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column(
"is_read", sa.Boolean(), server_default=sa.text("false"), nullable=False
),
sa.Column("read_at", sa.DateTime(timezone=True), 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(
"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.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["notification_id"], ["notifications.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["notification_id"], ["notifications.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "notification_id", name="uq_user_notifications_user_notification"),
sa.UniqueConstraint(
"user_id", "notification_id", name="uq_user_notifications_user_notification"
),
)
op.create_index(
"ix_user_notifications_user_created_at",
"user_notifications",
["user_id", sa.text("created_at DESC")],
)
op.create_index(
"ix_user_notifications_user_unread",
"user_notifications",
["user_id", "is_read"],
)
op.create_index("ix_user_notifications_user_created_at", "user_notifications", ["user_id", sa.text("created_at DESC")])
op.create_index("ix_user_notifications_user_unread", "user_notifications", ["user_id", "is_read"])
_enable_service_only_rls("user_notifications")
@@ -85,17 +172,29 @@ def downgrade() -> None:
def _enable_service_only_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"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)")
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_service_only_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"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
)
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")
@@ -42,27 +42,79 @@ def upgrade() -> None:
sa.Column("total_latency_ms", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("anonymized_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column(
"anonymized_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_anonymous_session_snapshots_anonymous_id", "anonymous_session_snapshots", ["anonymous_id"])
op.create_index("ix_anonymous_session_snapshots_created_at", "anonymous_session_snapshots", ["created_at"])
op.create_index("ix_anonymous_session_snapshots_question_type", "anonymous_session_snapshots", ["question_type"])
op.create_index(
"ix_anonymous_session_snapshots_anonymous_id",
"anonymous_session_snapshots",
["anonymous_id"],
)
op.create_index(
"ix_anonymous_session_snapshots_created_at",
"anonymous_session_snapshots",
["created_at"],
)
op.create_index(
"ix_anonymous_session_snapshots_question_type",
"anonymous_session_snapshots",
["question_type"],
)
_enable_service_role_all_rls("anonymous_session_snapshots")
op.create_table(
"user_feedback",
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column(
"id",
postgresql.UUID(as_uuid=True),
server_default=sa.text("gen_random_uuid()"),
nullable=False,
),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("feedback_type", sa.String(length=20), server_default=sa.text("'other'"), nullable=False),
sa.Column(
"feedback_type",
sa.String(length=20),
server_default=sa.text("'other'"),
nullable=False,
),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("images", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'[]'::jsonb"), nullable=False),
sa.Column("device_info", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column(
"images",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'::jsonb"),
nullable=False,
),
sa.Column(
"device_info",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.Column("app_version", sa.String(length=20), nullable=False),
sa.Column("os_version", sa.String(length=50), nullable=False),
sa.Column("status", sa.String(length=20), server_default=sa.text("'pending'"), 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(
"status",
sa.String(length=20),
server_default=sa.text("'pending'"),
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.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
@@ -70,12 +122,22 @@ def upgrade() -> None:
op.create_index("ix_user_feedback_created_at", "user_feedback", ["created_at"])
op.create_index("ix_user_feedback_status", "user_feedback", ["status"])
op.execute("COMMENT ON TABLE user_feedback IS '用户反馈表'")
op.execute("COMMENT ON COLUMN user_feedback.user_id IS '用户ID,NULL表示匿名(勾选不上传我的个人信息)'")
op.execute("COMMENT ON COLUMN user_feedback.feedback_type IS '反馈类型: bug/suggestion/other'")
op.execute(
"COMMENT ON COLUMN user_feedback.user_id IS '用户ID,NULL表示匿名(勾选不上传我的个人信息)'"
)
op.execute(
"COMMENT ON COLUMN user_feedback.feedback_type IS '反馈类型: bug/suggestion/other'"
)
op.execute("COMMENT ON COLUMN user_feedback.content IS '反馈内容,最多500字'")
op.execute("COMMENT ON COLUMN user_feedback.images IS '图片Storage路径列表,最多3张'")
op.execute("COMMENT ON COLUMN user_feedback.device_info IS '设备信息JSON,匿名时照样采集(不涉及隐私)'")
op.execute("COMMENT ON COLUMN user_feedback.status IS '处理状态: pending/processed'")
op.execute(
"COMMENT ON COLUMN user_feedback.images IS '图片Storage路径列表,最多3张'"
)
op.execute(
"COMMENT ON COLUMN user_feedback.device_info IS '设备信息JSON,匿名时照样采集(不涉及隐私)'"
)
op.execute(
"COMMENT ON COLUMN user_feedback.status IS '处理状态: pending/processed'"
)
_enable_service_role_all_rls("user_feedback")
@@ -89,7 +151,9 @@ def downgrade() -> None:
def _enable_service_role_all_rls(table_name: str) -> None:
op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY")
op.execute(f"CREATE POLICY service_role_all_{table_name} ON {table_name} FOR ALL TO service_role USING (true) WITH CHECK (true)")
op.execute(
f"CREATE POLICY service_role_all_{table_name} ON {table_name} FOR ALL TO service_role USING (true) WITH CHECK (true)"
)
def _drop_service_role_all_rls(table_name: str) -> None:
@@ -42,18 +42,51 @@ def upgrade() -> None:
sa.Column("price_milliunits", sa.BigInteger(), nullable=True),
sa.Column("ledger_event_id", sa.String(length=64), nullable=True),
sa.Column("signed_transaction_info", sa.Text(), nullable=False),
sa.Column("apple_payload", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column(
"apple_payload",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.Column("failure_code", sa.String(length=64), 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.CheckConstraint("environment in ('Sandbox', 'Production')", name="ck_apple_iap_transactions_environment"),
sa.CheckConstraint("status in ('received', 'verified', 'granted', 'failed', 'refunded', 'refunded_insufficient', 'revoked')", name="ck_apple_iap_transactions_status"),
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.CheckConstraint(
"environment in ('Sandbox', 'Production')",
name="ck_apple_iap_transactions_environment",
),
sa.CheckConstraint(
"status in ('received', 'verified', 'granted', 'failed', 'refunded', 'refunded_insufficient', 'revoked')",
name="ck_apple_iap_transactions_status",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("transaction_id", name="uq_apple_iap_transactions_transaction_id"),
sa.UniqueConstraint("ledger_event_id", name="uq_apple_iap_transactions_ledger_event_id"),
sa.UniqueConstraint(
"transaction_id", name="uq_apple_iap_transactions_transaction_id"
),
sa.UniqueConstraint(
"ledger_event_id", name="uq_apple_iap_transactions_ledger_event_id"
),
)
op.create_index(
"ix_apple_iap_transactions_user_created_at",
"apple_iap_transactions",
["user_id", sa.text("created_at DESC")],
)
op.create_index(
"ix_apple_iap_transactions_status_updated_at",
"apple_iap_transactions",
["status", sa.text("updated_at DESC")],
)
op.create_index("ix_apple_iap_transactions_user_created_at", "apple_iap_transactions", ["user_id", sa.text("created_at DESC")])
op.create_index("ix_apple_iap_transactions_status_updated_at", "apple_iap_transactions", ["status", sa.text("updated_at DESC")])
_enable_service_only_rls("apple_iap_transactions")
@@ -65,17 +98,29 @@ def downgrade() -> None:
def _enable_service_only_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"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)")
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_service_only_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"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
)
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")
@@ -31,16 +31,42 @@ def upgrade() -> None:
sa.Column("credits", sa.BigInteger(), nullable=False),
sa.Column("amount_cents", sa.BigInteger(), nullable=False),
sa.Column("currency", sa.String(length=8), nullable=False),
sa.Column("creem_payload", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column(
"creem_payload",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.Column("ledger_event_id", sa.String(length=128), 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.CheckConstraint("status in ('pending', 'completed', 'failed', 'refunded')", name="ck_creem_transactions_status"),
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.CheckConstraint(
"status in ('pending', 'completed', 'failed', 'refunded')",
name="ck_creem_transactions_status",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("checkout_id", name="uq_creem_transactions_checkout_id"),
)
op.create_index("ix_creem_transactions_user_created_at", "creem_transactions", ["user_id", sa.text("created_at DESC")])
op.create_index("ix_creem_transactions_status_updated_at", "creem_transactions", ["status", sa.text("updated_at DESC")])
op.create_index(
"ix_creem_transactions_user_created_at",
"creem_transactions",
["user_id", sa.text("created_at DESC")],
)
op.create_index(
"ix_creem_transactions_status_updated_at",
"creem_transactions",
["status", sa.text("updated_at DESC")],
)
_enable_service_only_rls("creem_transactions")
@@ -52,17 +78,29 @@ def downgrade() -> None:
def _enable_service_only_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"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)")
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_service_only_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"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}"
)
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")
@@ -0,0 +1,307 @@
"""Create invite referral, redeem code, and system audit tables.
Revision ID: 20260521_0002
Revises: 20260511_0001
Create Date: 2026-05-21 16:00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260521_0002"
down_revision: Union[str, Sequence[str], None] = "20260511_0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"invite_referrals",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("inviter_user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("invitee_user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("invite_code_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("invite_code_snapshot", sa.String(length=6), nullable=False),
sa.Column(
"bound_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"first_creem_transaction_id", postgresql.UUID(as_uuid=True), nullable=True
),
sa.Column("first_creem_paid_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("inviter_reward_event_id", sa.String(length=64), nullable=True),
sa.Column("invitee_reward_event_id", sa.String(length=64), nullable=True),
sa.Column(
"inviter_reward_granted_at", sa.DateTime(timezone=True), nullable=True
),
sa.Column(
"invitee_reward_granted_at", sa.DateTime(timezone=True), 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.ForeignKeyConstraint(
["first_creem_transaction_id"],
["creem_transactions.id"],
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["invite_code_id"], ["invite_codes.id"], ondelete="SET NULL"
),
sa.ForeignKeyConstraint(
["invitee_user_id"], ["profiles.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["inviter_user_id"], ["profiles.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.CheckConstraint(
"inviter_user_id <> invitee_user_id", name="ck_invite_referrals_not_self"
),
sa.UniqueConstraint(
"invitee_user_id", name="uq_invite_referrals_invitee_user_id"
),
sa.UniqueConstraint(
"inviter_reward_event_id",
name="uq_invite_referrals_inviter_reward_event_id",
),
sa.UniqueConstraint(
"invitee_reward_event_id",
name="uq_invite_referrals_invitee_reward_event_id",
),
)
op.create_index(
"ix_invite_referrals_inviter_bound_at",
"invite_referrals",
["inviter_user_id", sa.text("bound_at DESC")],
)
op.create_index(
"ix_invite_referrals_invitee_bound_at",
"invite_referrals",
["invitee_user_id", sa.text("bound_at DESC")],
)
_enable_service_only_rls("invite_referrals")
op.create_table(
"redeem_code_batches",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("batch_key", sa.String(length=64), nullable=False),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("notes", 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.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("batch_key", name="uq_redeem_code_batches_batch_key"),
)
_enable_service_only_rls("redeem_code_batches")
op.create_table(
"redeem_codes",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("batch_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("code", sa.String(length=32), nullable=False),
sa.Column("package_product_code", sa.String(length=32), nullable=False),
sa.Column("package_type", sa.String(length=16), nullable=False),
sa.Column("package_name_snapshot", sa.String(length=64), nullable=False),
sa.Column("credits", sa.BigInteger(), nullable=False),
sa.Column("sort_order", sa.BigInteger(), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("redeemed_by_user_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("redeemed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("redeem_event_id", sa.String(length=64), 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.ForeignKeyConstraint(
["batch_id"], ["redeem_code_batches.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["redeemed_by_user_id"], ["auth.users.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("code"),
sa.CheckConstraint(
"status in ('active', 'redeemed', 'disabled')",
name="ck_redeem_codes_status",
),
sa.CheckConstraint(
"((status = 'redeemed' and redeemed_by_user_id is not null and redeemed_at is not null and redeem_event_id is not null) or (status <> 'redeemed'))",
name="ck_redeem_codes_redeemed_shape",
),
)
op.create_index("ix_redeem_codes_code", "redeem_codes", ["code"], unique=True)
op.create_index(
"ix_redeem_codes_batch_status", "redeem_codes", ["batch_id", "status"]
)
op.create_index(
"ix_redeem_codes_redeemed_by",
"redeem_codes",
["redeemed_by_user_id", sa.text("redeemed_at DESC")],
)
_enable_service_only_rls("redeem_codes")
op.create_table(
"system_audit_logs",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("actor_user_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("target_user_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("action", sa.String(length=64), nullable=False),
sa.Column("entity_type", sa.String(length=32), nullable=False),
sa.Column("entity_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column(
"metadata",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
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.CheckConstraint(
"jsonb_typeof(metadata) = 'object'",
name="ck_system_audit_logs_metadata_object",
),
)
op.create_index(
"ix_system_audit_logs_action_created_at",
"system_audit_logs",
["action", sa.text("created_at DESC")],
)
op.create_index(
"ix_system_audit_logs_actor_created_at",
"system_audit_logs",
["actor_user_id", sa.text("created_at DESC")],
)
op.create_index(
"ix_system_audit_logs_target_created_at",
"system_audit_logs",
["target_user_id", sa.text("created_at DESC")],
)
_enable_service_only_rls("system_audit_logs")
op.execute(
"""
INSERT INTO invite_referrals (
id,
inviter_user_id,
invitee_user_id,
invite_code_id,
invite_code_snapshot,
bound_at,
created_at,
updated_at
)
SELECT
gen_random_uuid(),
p.referred_by,
p.id,
ic.id,
ic.code,
p.created_at,
p.created_at,
p.updated_at
FROM profiles p
JOIN LATERAL (
SELECT id, code
FROM invite_codes
WHERE owner_id = p.referred_by
ORDER BY created_at DESC
LIMIT 1
) ic ON TRUE
WHERE p.referred_by IS NOT NULL
ON CONFLICT (invitee_user_id) DO NOTHING
"""
)
def downgrade() -> None:
_drop_service_only_rls("system_audit_logs")
op.drop_table("system_audit_logs")
_drop_service_only_rls("redeem_codes")
op.drop_index("ix_redeem_codes_redeemed_by", table_name="redeem_codes")
op.drop_index("ix_redeem_codes_batch_status", table_name="redeem_codes")
op.drop_index("ix_redeem_codes_code", table_name="redeem_codes")
op.drop_table("redeem_codes")
_drop_service_only_rls("redeem_code_batches")
op.drop_table("redeem_code_batches")
_drop_service_only_rls("invite_referrals")
op.drop_index("ix_invite_referrals_invitee_bound_at", table_name="invite_referrals")
op.drop_index("ix_invite_referrals_inviter_bound_at", table_name="invite_referrals")
op.drop_table("invite_referrals")
def _enable_service_only_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_service_only_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} DISABLE ROW LEVEL SECURITY")