diff --git a/backend/alembic/versions/2aa73d3231c2_account_invites_add_revoked_at_and_.py b/backend/alembic/versions/2aa73d3231c2_account_invites_add_revoked_at_and_.py new file mode 100644 index 00000000..db4f258f --- /dev/null +++ b/backend/alembic/versions/2aa73d3231c2_account_invites_add_revoked_at_and_.py @@ -0,0 +1,30 @@ +"""account_invites add revoked_at and email_sent_at + +Revision ID: 2aa73d3231c2 +Revises: e1af7ab57ceb +Create Date: 2026-05-06 07:28:28.514384 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '2aa73d3231c2' +down_revision: Union[str, None] = 'e1af7ab57ceb' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("account_invites", sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("account_invites", sa.Column("email_sent_at", sa.DateTime(timezone=True), nullable=True)) + op.create_index("ix_account_invites_revoked_at", "account_invites", ["revoked_at"]) + + +def downgrade() -> None: + op.drop_index("ix_account_invites_revoked_at", table_name="account_invites") + op.drop_column("account_invites", "email_sent_at") + op.drop_column("account_invites", "revoked_at") diff --git a/backend/app/models/account_invite.py b/backend/app/models/account_invite.py index 43b3ed56..84009194 100644 --- a/backend/app/models/account_invite.py +++ b/backend/app/models/account_invite.py @@ -27,6 +27,8 @@ class AccountInvite(Base): expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + email_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) # Relationships account: Mapped["Account"] = relationship("Account") @@ -37,6 +39,10 @@ class AccountInvite(Base): def is_used(self) -> bool: return self.accepted_by_id is not None + @property + def is_revoked(self) -> bool: + return self.revoked_at is not None + @property def is_expired(self) -> bool: if self.expires_at is None: @@ -45,4 +51,4 @@ class AccountInvite(Base): @property def is_valid(self) -> bool: - return not self.is_used and not self.is_expired + return not self.is_used and not self.is_expired and not self.is_revoked diff --git a/backend/tests/test_account_invite_model.py b/backend/tests/test_account_invite_model.py new file mode 100644 index 00000000..4c62266b --- /dev/null +++ b/backend/tests/test_account_invite_model.py @@ -0,0 +1,27 @@ +import pytest +from datetime import datetime, timezone, timedelta +from app.models.account_invite import AccountInvite + + +def make_invite(**kwargs): + return AccountInvite( + account_id=kwargs.get("account_id", "00000000-0000-0000-0000-000000000001"), + invited_by_id=kwargs.get("invited_by_id", "00000000-0000-0000-0000-000000000002"), + email=kwargs.get("email", "x@y.com"), + code=kwargs.get("code", "ABCD1234"), + role=kwargs.get("role", "engineer"), + accepted_by_id=kwargs.get("accepted_by_id"), + expires_at=kwargs.get("expires_at"), + revoked_at=kwargs.get("revoked_at"), + ) + + +def test_invite_revoked_is_invalid(): + invite = make_invite(revoked_at=datetime.now(timezone.utc)) + assert invite.is_revoked is True + assert invite.is_valid is False + + +def test_invite_unrevoked_unexpired_unused_is_valid(): + invite = make_invite(expires_at=datetime.now(timezone.utc) + timedelta(days=7)) + assert invite.is_valid is True