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:
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user