feat(org): platform invites, roles, org sharing (v2 phase A1-1)

- New platform_invites table (migration a1b2c3d4e5f1) + PlatformInviteRow
- Owner-only endpoints in admin_users.py: issue/list/revoke platform invites
- invites.py: two-kind resolution (org + platform), email-supplied accept path
- auth.py /me gains needs_onboarding (manager, 0 orgs, not owner)
- organizations.py: transfer-ownership, leave (last owner guard), list/revoke org invites
- contracts + web UI: admin/users invite modal, accept-invite platform variant, onboarding gating
- Tests: test_org_platform_invites.py (18), org-sharing.test.tsx; report under docs/plan/reports

Note: (app)/settings "اشتراک‌گذاری سازمان" section (1.10) remains presentational work;
endpoints are done and tested.
This commit is contained in:
mjalmoudy94
2026-10-14 09:14:09 +03:30
parent 3c8296e354
commit 5095a29df9
20 changed files with 1933 additions and 37 deletions
@@ -5,6 +5,75 @@ Use ``from app.core.database import Base`` and the helpers in
``app.infrastructure.db.models`` here: that module imports this one (import cycle).
Foreign keys to existing tables are written as plain strings, e.g.
``ForeignKey("organizations.id", ondelete="CASCADE")``. See docs/plan/PLAN-v2.md §6.
``platform_invites`` is the door into the product (D12). Unlike an *organization*
invite — which joins someone to an org that already exists — this one creates a
brand-new platform account, so it is a capability rather than a message:
* it is stored only as a hash, exactly like an org invite;
* it is single-use and time-limited (``PLATFORM_INVITE_TTL_HOURS``);
* the *email is optional*. A link the owner hands to somebody in a chat is a
bearer token; an email pinned to it is a stronger promise but also one more
thing for the owner to type, and the plan asks for both to be possible. When
the email is set, the account that redeems the link is bound to it;
* redeeming it creates an account with the ``manager`` platform role, which is
what lets the holder go on to create organizations.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.domain.base import utcnow
from app.infrastructure.db.columns import created, pk
class PlatformInviteRow(Base):
__tablename__ = "platform_invites"
__table_args__ = (
Index("ix_platform_invite_token", "token_hash", unique=True),
Index("ix_platform_invite_email", "email"),
)
id: Mapped[str] = pk()
#: sha256 of the raw token. The token itself is shown exactly once.
token_hash: Mapped[str] = mapped_column(String(64), nullable=False)
#: Optional. When set, the redeeming account is bound to it.
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
#: The platform role the redeemed account receives. Manager only, for now.
platform_role: Mapped[str] = mapped_column(String(16), default="manager", nullable=False)
created_by_user_id: Mapped[str] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
used_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
note: Mapped[str] = mapped_column(Text, default="", nullable=False)
created_at: Mapped[datetime] = created()
@property
def is_redeemed(self) -> bool:
return self.used_at is not None
@property
def is_revoked(self) -> bool:
return self.revoked_at is not None
def is_expired(self, now: datetime | None = None) -> bool:
if self.used_at is not None or self.revoked_at is not None:
return True
reference = now or utcnow()
return self.expires_at <= reference
__all__ = ["PlatformInviteRow"]