ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمان‌های مدیریت‌شده

- users.is_platform_owner → users.platform_role (owner|manager|user) با مهاجرت
  d4e5f6a7b8c9 و backfill. ایندکس یکتای جزئی uq_single_platform_owner تضمین
  می‌کند دقیقاً یک مالک وجود داشته باشد — در سطح دیتابیس، نه فقط در کد.
- ثبت‌نام عمومی حذف شد (POST /auth/signup). حساب‌ها فقط با POST /admin/users
  (فقط مالک، با رمز موقت یک‌بارمصرف) یا با پذیرش لینک دعوت ساخته می‌شوند.
  در مسیر دعوت، اگر حساب وجود نداشته باشد خودِ دعوت‌شده رمز تعیین می‌کند؛ اگر
  وجود داشته باشد لینک هرگز جای reset رمز را نمی‌گیرد.
- ساخت سازمان فقط برای مالک و مدیران؛ کاربر عادی با دعوت عضو می‌شود.
- تنزل یا حذف مدیر، سازمان‌هایش را به مالک سامانه منتقل می‌کند (مالکیت + عضویت،
  با audit). حساب مالک از API تغییرناپذیر است؛ جابه‌جایی صندلی فقط با
  POST /admin/transfer-ownership و در یک تراکنش انجام می‌شود.
- افزودن POST /auth/change-password برای تبدیل رمز موقت به رمز خصوصی.
- فرانت: صفحهٔ /admin/users، صفحهٔ /account، بازنویسی /accept-invite، حذف /signup،
  گارد /onboarding برای کاربر عادی، و نشان نقش پلتفرم در پوستهٔ اپ.
- PRAGMA foreign_keys=ON برای SQLite تا ON DELETE CASCADE در dev و prod یکسان
  رفتار کند (پیش‌تر حذف کاربر، عضویت‌هایش را یتیم می‌گذاشت).
- تست: ۳۰۰ تست بک‌اند (شامل tests/test_platform_hierarchy.py با ۲۲ تست) و ۶۴ تست
  فرانت‌اند؛ ruff، mypy، tsc، eslint و build تمیز.
This commit is contained in:
mjalmoudy94
2026-10-13 21:58:41 +03:30
parent 6937b88ca7
commit 8eb631aedf
41 changed files with 2952 additions and 454 deletions
@@ -0,0 +1,87 @@
"""Single platform owner; users gain a platform role
The old schema had ``users.is_platform_owner`` as a plain boolean with no
uniqueness guard, so nothing stopped a second (or tenth) platform owner from
appearing. This migration replaces the boolean with ``platform_role``
(``owner`` | ``manager`` | ``user``) and makes "exactly one owner" a *database*
invariant through a partial unique index — a second ``owner`` INSERT now fails
even if some future code path forgets to check.
Backfill rules:
- any row that was ``is_platform_owner = true`` becomes ``owner``;
- if a legacy database somehow had several owners, only the earliest (by
``created_at``) keeps the role and the rest are demoted to ``manager``;
- everything else becomes ``user``.
Revision ID: d4e5f6a7b8c9
Revises: c2d3e4f5b6a7
Create Date: 2026-10-13 18:20:00.000000
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "d4e5f6a7b8c9"
down_revision: str | None = "c2d3e4f5b6a7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_OWNER_WHERE = sa.text("platform_role = 'owner'")
def upgrade() -> None:
op.add_column(
"users",
sa.Column(
"platform_role",
sa.String(length=16),
nullable=False,
server_default="user",
),
)
# Defensive backfill: keep only the earliest owner, demote any others.
# Written with the boolean literal ``FALSE`` because both SQLite (>=3.23)
# and PostgreSQL accept it, while ``0`` would be a type error on Postgres.
op.execute(
"UPDATE users SET is_platform_owner = FALSE "
"WHERE is_platform_owner AND id NOT IN "
"(SELECT id FROM users WHERE is_platform_owner ORDER BY created_at LIMIT 1)"
)
op.execute("UPDATE users SET platform_role = 'owner' WHERE is_platform_owner")
op.create_index(
"ix_user_platform_role",
"users",
["platform_role"],
unique=False,
)
op.create_index(
"uq_single_platform_owner",
"users",
["platform_role"],
unique=True,
sqlite_where=_OWNER_WHERE,
postgresql_where=_OWNER_WHERE,
)
op.drop_column("users", "is_platform_owner")
def downgrade() -> None:
op.add_column(
"users",
sa.Column(
"is_platform_owner",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
op.execute("UPDATE users SET is_platform_owner = TRUE WHERE platform_role = 'owner'")
op.drop_index("uq_single_platform_owner", table_name="users")
op.drop_index("ix_user_platform_role", table_name="users")
op.drop_column("users", "platform_role")