diff --git a/migrations/models/5_20260107135803_update.py b/migrations/models/5_20260107135803_update.py new file mode 100644 index 0000000..2b43fd2 --- /dev/null +++ b/migrations/models/5_20260107135803_update.py @@ -0,0 +1,151 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX IF EXISTS "uid_user_telegra_66ffbd"; + ALTER TABLE "subscription" DROP CONSTRAINT IF EXISTS "uid_subscriptio_placeme_8fcb8f"; + ALTER TABLE "subscription" DROP CONSTRAINT IF EXISTS "fk_subscrip_user_6b9f4b8e"; + CREATE TABLE IF NOT EXISTS "telegram_user" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "telegram_id" BIGINT NOT NULL UNIQUE, + "username" VARCHAR(255), + "first_name" VARCHAR(255), + "last_name" VARCHAR(255) +); +CREATE INDEX IF NOT EXISTS "idx_telegram_us_telegra_3a3100" ON "telegram_user" ("telegram_id"); + INSERT INTO "telegram_user" ( + "id", + "created_at", + "updated_at", + "deleted_at", + "telegram_id", + "username", + "first_name", + "last_name" +) +SELECT + "id", + "created_at", + "updated_at", + "deleted_at", + "telegram_id", + "username", + "first_name", + "last_name" +FROM "user" +WHERE "telegram_id" IS NOT NULL +ON CONFLICT ("id") DO NOTHING; + ALTER TABLE "subscription" RENAME COLUMN "subscriber_id" TO "telegram_user_id"; + ALTER TABLE "user" ADD "telegram_user_id" UUID UNIQUE; + UPDATE "user" SET "telegram_user_id" = "id"; + ALTER TABLE "user" ALTER COLUMN "telegram_user_id" SET NOT NULL; + ALTER TABLE "user" DROP COLUMN "first_name"; + ALTER TABLE "user" DROP COLUMN "username"; + ALTER TABLE "user" DROP COLUMN "telegram_id"; + ALTER TABLE "user" DROP COLUMN "last_name"; + ALTER TABLE "subscription" ADD CONSTRAINT "fk_subscrip_telegram_ab72b5bf" FOREIGN KEY ("telegram_user_id") REFERENCES "telegram_user" ("id") ON DELETE CASCADE; + CREATE UNIQUE INDEX IF NOT EXISTS "uid_subscriptio_placeme_75b40b" ON "subscription" ("placement_id", "telegram_user_id"); + ALTER TABLE "user" ADD CONSTRAINT "fk_user_telegram_548d951e" FOREIGN KEY ("telegram_user_id") REFERENCES "telegram_user" ("id") ON DELETE CASCADE;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX IF EXISTS "uid_subscriptio_placeme_75b40b"; + ALTER TABLE "subscription" DROP CONSTRAINT IF EXISTS "fk_subscrip_telegram_ab72b5bf"; + ALTER TABLE "user" DROP CONSTRAINT IF EXISTS "fk_user_telegram_548d951e"; + ALTER TABLE "user" ADD "first_name" VARCHAR(255); + ALTER TABLE "user" ADD "username" VARCHAR(255); + ALTER TABLE "user" ADD "telegram_id" BIGINT NOT NULL UNIQUE; + ALTER TABLE "user" ADD "last_name" VARCHAR(255); + ALTER TABLE "user" DROP COLUMN "telegram_user_id"; + ALTER TABLE "subscription" RENAME COLUMN "telegram_user_id" TO "subscriber_id"; + DROP TABLE IF EXISTS "telegram_user"; + ALTER TABLE "subscription" ADD CONSTRAINT "fk_subscrip_user_6b9f4b8e" FOREIGN KEY ("subscriber_id") REFERENCES "user" ("id") ON DELETE CASCADE; + CREATE UNIQUE INDEX IF NOT EXISTS "uid_subscriptio_placeme_8fcb8f" ON "subscription" ("placement_id", "subscriber_id");""" + + +MODELS_STATE = ( + "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" + "1lQhHbSyJe3k/3n5/X8nR5KJ" +) diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index f7c7eef..a875fc9 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -23,6 +23,24 @@ class Postgres(DatabaseBase, Database): async def update_user(self, user: domain.User) -> None: await user.save() + async def get_telegram_user( + self, + telegram_user_id: uuid.UUID | None = None, + telegram_id: int | None = None, + ) -> domain.TelegramUser | None: + if telegram_user_id: + return await domain.TelegramUser.get_or_none(id=telegram_user_id) + if telegram_id: + return await domain.TelegramUser.get_or_none(telegram_id=telegram_id) + + raise ValueError('Either telegram_user_id or telegram_id must be provided') + + async def create_telegram_user(self, telegram_user: domain.TelegramUser) -> None: + await telegram_user.save() + + async def update_telegram_user(self, telegram_user: domain.TelegramUser) -> None: + await telegram_user.save() + async def create_workspace(self, workspace: domain.Workspace) -> None: await workspace.save() @@ -75,14 +93,14 @@ class Postgres(DatabaseBase, Database): ) -> domain.WorkspaceUser | None: return ( await domain.WorkspaceUser.filter(workspace_id=workspace_id, user_id=user_id) - .prefetch_related('workspace', 'user', 'permissions', 'permission_scopes') + .prefetch_related('workspace', 'user', 'user__telegram_user', 'permissions', 'permission_scopes') .first() ) async def get_workspace_members(self, workspace_id: uuid.UUID) -> list[domain.WorkspaceUser]: return ( await domain.WorkspaceUser.filter(workspace_id=workspace_id) - .prefetch_related('workspace', 'user', 'permissions', 'permission_scopes') + .prefetch_related('workspace', 'user__telegram_user', 'permissions', 'permission_scopes') .order_by('created_at') .all() ) @@ -90,7 +108,7 @@ class Postgres(DatabaseBase, Database): async def get_workspace_member(self, workspace_user_id: uuid.UUID) -> domain.WorkspaceUser | None: return ( await domain.WorkspaceUser.filter(id=workspace_user_id) - .prefetch_related('workspace', 'user', 'permissions', 'permission_scopes') + .prefetch_related('workspace', 'user__telegram_user', 'permissions', 'permission_scopes') .first() ) @@ -103,7 +121,7 @@ class Postgres(DatabaseBase, Database): async def get_workspace_invite(self, invite_id: uuid.UUID) -> domain.WorkspaceInvite | None: return ( await domain.WorkspaceInvite.filter(id=invite_id) - .prefetch_related('workspace', 'user', 'invited_by') + .prefetch_related('workspace', 'user__telegram_user', 'invited_by__telegram_user') .first() ) @@ -115,7 +133,7 @@ class Postgres(DatabaseBase, Database): async def get_workspace_invites(self, workspace_id: uuid.UUID) -> list[domain.WorkspaceInvite]: return ( await domain.WorkspaceInvite.filter(workspace_id=workspace_id) - .prefetch_related('user', 'invited_by') + .prefetch_related('user__telegram_user', 'invited_by__telegram_user') .order_by('created_at') .all() ) @@ -165,14 +183,24 @@ class Postgres(DatabaseBase, Database): async def get_user(self, user_id: uuid.UUID | None = None, telegram_id: int | None = None) -> domain.User | None: if user_id: - return await domain.User.get_or_none(id=user_id) + return ( + await domain.User.filter(id=user_id).prefetch_related('telegram_user').first() + ) elif telegram_id: - return await domain.User.get_or_none(telegram_id=telegram_id) + return ( + await domain.User.filter(telegram_user__telegram_id=telegram_id) + .prefetch_related('telegram_user') + .first() + ) raise ValueError('Either user_id or telegram_id must be provided') async def get_user_by_username(self, username: str) -> domain.User | None: - return await domain.User.get_or_none(username__iexact=username) + return ( + await domain.User.filter(telegram_user__username__iexact=username) + .prefetch_related('telegram_user') + .first() + ) async def get_login_token(self, token: str) -> domain.LoginToken | None: return await domain.LoginToken.get_or_none(token=token) @@ -444,9 +472,11 @@ class Postgres(DatabaseBase, Database): await subscription.save() async def get_subscription_by_subscriber_and_placement( - self, subscriber_id: uuid.UUID, placement_id: uuid.UUID + self, telegram_user_id: uuid.UUID, placement_id: uuid.UUID ) -> domain.Subscription | None: - return await domain.Subscription.get_or_none(subscriber_id=subscriber_id, placement_id=placement_id) + return await domain.Subscription.get_or_none( + telegram_user_id=telegram_user_id, placement_id=placement_id + ) async def update_subscription(self, subscription: domain.Subscription) -> None: await subscription.save() @@ -471,28 +501,28 @@ class Postgres(DatabaseBase, Database): return await query.all() async def get_active_subscriptions_by_subscriber_and_project( - self, subscriber_id: uuid.UUID, project_id: uuid.UUID + self, telegram_user_id: uuid.UUID, project_id: uuid.UUID ) -> list[domain.Subscription]: return ( await domain.Subscription.filter( - subscriber_id=subscriber_id, + telegram_user_id=telegram_user_id, placement__project_id=project_id, status=domain.SubscriptionStatus.ACTIVE, ) - .prefetch_related('placement', 'subscriber') + .prefetch_related('placement', 'telegram_user') .all() ) async def get_active_subscription_by_subscriber_and_project( - self, subscriber_id: uuid.UUID, project_id: uuid.UUID + self, telegram_user_id: uuid.UUID, project_id: uuid.UUID ) -> domain.Subscription | None: return ( await domain.Subscription.filter( - subscriber_id=subscriber_id, + telegram_user_id=telegram_user_id, placement__project_id=project_id, status=domain.SubscriptionStatus.ACTIVE, ) - .prefetch_related('placement', 'subscriber') + .prefetch_related('placement', 'telegram_user') .first() ) diff --git a/src/domain/__init__.py b/src/domain/__init__.py index 284d265..27dff0d 100644 --- a/src/domain/__init__.py +++ b/src/domain/__init__.py @@ -1,5 +1,6 @@ __all__ = ( 'User', + 'TelegramUser', 'Workspace', 'WorkspaceUser', 'WorkspaceInvite', @@ -101,6 +102,7 @@ from .purchase import ( ) from .subscription import Subscription, SubscriptionStatus from .user import User +from .telegram_user import TelegramUser from .workspace import ( PermissionKey, PermissionScopeType, diff --git a/src/domain/subscription.py b/src/domain/subscription.py index 54e133e..aeb367b 100644 --- a/src/domain/subscription.py +++ b/src/domain/subscription.py @@ -8,7 +8,7 @@ from .base import TimestampedModel if TYPE_CHECKING: from .placement import Placement - from .user import User + from .telegram_user import TelegramUser class SubscriptionStatus(str, enum.Enum): @@ -24,14 +24,14 @@ class Subscription(TimestampedModel): placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField( 'models.Placement', related_name='subscriptions', on_delete=fields.CASCADE, index=True ) - subscriber: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField( - 'models.User', related_name='subscriptions', on_delete=fields.CASCADE, index=True + telegram_user: fields.ForeignKeyRelation['TelegramUser'] = fields.ForeignKeyField( + 'models.TelegramUser', related_name='subscriptions', on_delete=fields.CASCADE, index=True ) if TYPE_CHECKING: placement_id: UUID - subscriber_id: UUID + telegram_user_id: UUID class Meta: table = 'subscription' - unique_together = (('placement_id', 'subscriber_id'),) + unique_together = (('placement_id', 'telegram_user_id'),) diff --git a/src/domain/telegram_user.py b/src/domain/telegram_user.py new file mode 100644 index 0000000..42cf8a0 --- /dev/null +++ b/src/domain/telegram_user.py @@ -0,0 +1,13 @@ +from tortoise import fields + +from .base import TimestampedModel + + +class TelegramUser(TimestampedModel): + telegram_id = fields.BigIntField(unique=True, index=True) + username = fields.CharField(max_length=255, null=True) + first_name = fields.CharField(max_length=255, null=True) + last_name = fields.CharField(max_length=255, null=True) + + class Meta: + table = 'telegram_user' diff --git a/src/domain/user.py b/src/domain/user.py index a6c2b49..87424c7 100644 --- a/src/domain/user.py +++ b/src/domain/user.py @@ -1,13 +1,21 @@ +from typing import TYPE_CHECKING +from uuid import UUID + from tortoise import fields from .base import TimestampedModel +if TYPE_CHECKING: + from .telegram_user import TelegramUser + class User(TimestampedModel): - telegram_id = fields.BigIntField(unique=True, index=True) - username = fields.CharField(max_length=255, null=True) - first_name = fields.CharField(max_length=255, null=True) - last_name = fields.CharField(max_length=255, null=True) + telegram_user: fields.ForeignKeyRelation['TelegramUser'] = fields.OneToOneField( + 'models.TelegramUser', related_name='user', on_delete=fields.CASCADE, index=True + ) + + if TYPE_CHECKING: + telegram_user_id: UUID class Meta: table = 'user' diff --git a/src/dto/workspace.py b/src/dto/workspace.py index d82682c..5e621ae 100644 --- a/src/dto/workspace.py +++ b/src/dto/workspace.py @@ -49,8 +49,10 @@ class WorkspaceMemberOutput(pydantic.BaseModel): @classmethod def from_domain(cls, member: domain.WorkspaceUser) -> 'WorkspaceMemberOutput': - if member.user is None: - raise ValueError('Workspace member user relation is not loaded') + if member.user is None or member.user.telegram_user is None: + raise ValueError('Workspace member user relation is not fully loaded') + + telegram_user = member.user.telegram_user permissions_map: dict[domain.PermissionKey, list[WorkspacePermissionScopeOutput]] = {} @@ -67,8 +69,8 @@ class WorkspaceMemberOutput(pydantic.BaseModel): status=member.status, user=WorkspaceMemberUserOutput( id=member.user.id, - telegram_id=member.user.telegram_id, - username=member.user.username, + telegram_id=telegram_user.telegram_id, + username=telegram_user.username, ), permissions=[ WorkspacePermissionOutput(key=key, scopes=scopes) @@ -150,21 +152,26 @@ class WorkspaceInviteOutput(pydantic.BaseModel): @classmethod def from_domain(cls, invite: domain.WorkspaceInvite) -> 'WorkspaceInviteOutput': - if invite.user is None or invite.invited_by is None: - raise ValueError('Workspace invite relations are not loaded') + if ( + invite.user is None + or invite.invited_by is None + or invite.user.telegram_user is None + or invite.invited_by.telegram_user is None + ): + raise ValueError('Workspace invite relations are not fully loaded') return cls( id=invite.id, status=invite.status, user=WorkspaceMemberUserOutput( id=invite.user.id, - telegram_id=invite.user.telegram_id, - username=invite.user.username, + telegram_id=invite.user.telegram_user.telegram_id, + username=invite.user.telegram_user.username, ), invited_by=WorkspaceMemberUserOutput( id=invite.invited_by.id, - telegram_id=invite.invited_by.telegram_id, - username=invite.invited_by.username, + telegram_id=invite.invited_by.telegram_user.telegram_id, + username=invite.invited_by.telegram_user.username, ), ) diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index bd9df34..08076bb 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -65,6 +65,16 @@ class Database(typing.Protocol): async def create_user(self, user: domain.User) -> None: ... + async def get_telegram_user( + self, + telegram_user_id: UUID | None = None, + telegram_id: int | None = None, + ) -> domain.TelegramUser | None: ... + + async def create_telegram_user(self, telegram_user: domain.TelegramUser) -> None: ... + + async def update_telegram_user(self, telegram_user: domain.TelegramUser) -> None: ... + async def create_workspace(self, workspace: domain.Workspace) -> None: ... async def update_workspace(self, workspace: domain.Workspace) -> None: ... @@ -222,7 +232,7 @@ class Database(typing.Protocol): async def create_subscription(self, subscription: domain.Subscription) -> None: ... async def get_subscription_by_subscriber_and_placement( - self, subscriber_id: UUID, placement_id: UUID + self, telegram_user_id: UUID, placement_id: UUID ) -> domain.Subscription | None: ... async def update_subscription(self, subscription: domain.Subscription) -> None: ... @@ -236,11 +246,11 @@ class Database(typing.Protocol): ) -> list[domain.Subscription]: ... async def get_active_subscriptions_by_subscriber_and_project( - self, subscriber_id: UUID, project_id: UUID + self, telegram_user_id: UUID, project_id: UUID ) -> list[domain.Subscription]: ... async def get_active_subscription_by_subscriber_and_project( - self, subscriber_id: UUID, project_id: UUID + self, telegram_user_id: UUID, project_id: UUID ) -> domain.Subscription | None: ... # Count methods @@ -365,7 +375,11 @@ class Usecase: if workspace: return workspace - workspace_name = user.username or f'Workspace {user.telegram_id}' + if not user.telegram_user: + raise domain.UserNotFound(user.id) + + telegram_user = user.telegram_user + workspace_name = telegram_user.username or f'Workspace {telegram_user.telegram_id}' workspace = domain.Workspace(name=workspace_name) async with self.database.transaction(): diff --git a/src/usecase/auth/create_telegram_login_token.py b/src/usecase/auth/create_telegram_login_token.py index e74ff3a..51a5955 100644 --- a/src/usecase/auth/create_telegram_login_token.py +++ b/src/usecase/auth/create_telegram_login_token.py @@ -11,12 +11,17 @@ if typing.TYPE_CHECKING: async def create_telegram_login_token(self: 'Usecase', telegram_id: int, username: str | None) -> str: + telegram_user = await self.database.get_telegram_user(telegram_id=telegram_id) + if not telegram_user: + telegram_user = domain.TelegramUser(telegram_id=telegram_id, username=username) + await self.database.create_telegram_user(telegram_user) + elif username and telegram_user.username != username: + telegram_user.username = username + await self.database.update_telegram_user(telegram_user) + user = await self.database.get_user(telegram_id=telegram_id) if not user: - user = domain.User( - telegram_id=telegram_id, - username=username, - ) + user = domain.User(telegram_user=telegram_user) await self.database.create_user(user) token = secrets.token_urlsafe(32) diff --git a/src/usecase/auth/get_jwt_by_telegram_id.py b/src/usecase/auth/get_jwt_by_telegram_id.py index c753548..531b8f8 100644 --- a/src/usecase/auth/get_jwt_by_telegram_id.py +++ b/src/usecase/auth/get_jwt_by_telegram_id.py @@ -13,33 +13,38 @@ async def get_jwt_by_telegram_id( first_name: str | None = None, last_name: str | None = None, ) -> dto.ValidateLoginTokenOutput: - user = await self.database.get_user(telegram_id=telegram_id) - if not user: - user = domain.User( + telegram_user = await self.database.get_telegram_user(telegram_id=telegram_id) + if not telegram_user: + telegram_user = domain.TelegramUser( telegram_id=telegram_id, username=username, first_name=first_name, last_name=last_name, ) - await self.database.create_user(user) + await self.database.create_telegram_user(telegram_user) else: updated = False - if username is not None and user.username != username: - user.username = username + if username is not None and telegram_user.username != username: + telegram_user.username = username updated = True - if first_name is not None and user.first_name != first_name: - user.first_name = first_name + if first_name is not None and telegram_user.first_name != first_name: + telegram_user.first_name = first_name updated = True - if last_name is not None and user.last_name != last_name: - user.last_name = last_name + if last_name is not None and telegram_user.last_name != last_name: + telegram_user.last_name = last_name updated = True if updated: - await self.database.update_user(user) + await self.database.update_telegram_user(telegram_user) + + user = await self.database.get_user(telegram_id=telegram_id) + if not user: + user = domain.User(telegram_user=telegram_user) + await self.database.create_user(user) access_token = self.jwt_encoder.encode_access_token( user_id=user.id, - telegram_id=user.telegram_id, - username=user.username, + telegram_id=telegram_user.telegram_id, + username=telegram_user.username, ) return dto.ValidateLoginTokenOutput( diff --git a/src/usecase/auth/get_me.py b/src/usecase/auth/get_me.py index 5688e5d..6d752b4 100644 --- a/src/usecase/auth/get_me.py +++ b/src/usecase/auth/get_me.py @@ -13,10 +13,15 @@ async def get_me(self: 'Usecase', user_id: uuid.UUID) -> dto.UserOutput: if not user: raise domain.UserNotFound(user_id) + if not user.telegram_user: + raise domain.UserNotFound(user_id) + + telegram_user = user.telegram_user + return dto.UserOutput( id=user.id, - telegram_id=user.telegram_id, - username=user.username, - first_name=user.first_name, - last_name=user.last_name, + telegram_id=telegram_user.telegram_id, + username=telegram_user.username, + first_name=telegram_user.first_name, + last_name=telegram_user.last_name, ) diff --git a/src/usecase/auth/validate_login_token.py b/src/usecase/auth/validate_login_token.py index 26cb2c2..69d4ee0 100644 --- a/src/usecase/auth/validate_login_token.py +++ b/src/usecase/auth/validate_login_token.py @@ -26,10 +26,14 @@ async def validate_login_token(self: 'Usecase', input: dto.ValidateLoginTokenInp await self.database.mark_token_as_used(input.token) + telegram_user = user.telegram_user + if telegram_user is None: + raise domain.UserNotFound(login_token.user_id) + access_token = self.jwt_encoder.encode_access_token( user_id=user.id, - telegram_id=user.telegram_id, - username=user.username, + telegram_id=telegram_user.telegram_id, + username=telegram_user.username, ) return dto.ValidateLoginTokenOutput( diff --git a/src/usecase/project/disconnect_project_by_tg_id.py b/src/usecase/project/disconnect_project_by_tg_id.py index 16dbc1d..d9cfb17 100644 --- a/src/usecase/project/disconnect_project_by_tg_id.py +++ b/src/usecase/project/disconnect_project_by_tg_id.py @@ -14,6 +14,9 @@ async def disconnect_project_by_tg_id(self: 'Usecase', input: dto.DisconnectProj if not user: log.warning(f'User with telegram_id {input.user_telegram_id} not found when disconnecting channel') return + if user.telegram_user is None: + log.warning('User %s missing telegram profile when disconnecting channel', user.id) + return project = await self.database.get_project_for_user_by_telegram(user.id, input.telegram_id) if not project: diff --git a/src/usecase/project/tg_add_project.py b/src/usecase/project/tg_add_project.py index d78453a..0049e18 100644 --- a/src/usecase/project/tg_add_project.py +++ b/src/usecase/project/tg_add_project.py @@ -52,6 +52,18 @@ async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto input.user_telegram_id, ) raise domain.UserNotFound() + if user.telegram_user is None: + log.warning('User %s missing telegram profile when connecting channel', user.id) + await self.telegram_bot.send_message( + '⚠️ Произошла ошибка при обработке вашего профиля. Пожалуйста, повторите авторизацию.', + input.user_telegram_id, + ) + raise domain.UserNotFound() + + if user.telegram_user is None: + raise domain.UserNotFound(user.id) + + telegram_user = user.telegram_user memberships = await self.database.get_user_workspaces(user.id) workspaces: list[domain.Workspace] = [] @@ -154,10 +166,10 @@ async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto f'Бот добавлен в канал "{input.title}".\n\n' f'У вас {len(workspaces)} рабочих пространств. ' 'Нажмите кнопку ниже чтобы выбрать, где создать проект.', - user.telegram_id, + telegram_user.telegram_id, buttons, ) - log.info('Pending channel notification sent to user %s for channel %s', user.telegram_id, channel.id) + log.info('Pending channel notification sent to user %s for channel %s', telegram_user.telegram_id, channel.id) return None diff --git a/src/usecase/project/update_project_permissions.py b/src/usecase/project/update_project_permissions.py index 7ae4028..33af326 100644 --- a/src/usecase/project/update_project_permissions.py +++ b/src/usecase/project/update_project_permissions.py @@ -21,6 +21,9 @@ async def update_project_permissions(self: 'Usecase', input: dto.UpdateProjectPe if not user: log.warning(f'User with telegram_id {input.user_telegram_id} not found when updating channel permissions') return + if user.telegram_user is None: + log.warning(f'User %s missing telegram profile when updating permissions', user.id) + return project = await self.database.get_project_for_user_by_telegram(user.id, input.telegram_id) if not project: @@ -34,7 +37,7 @@ async def update_project_permissions(self: 'Usecase', input: dto.UpdateProjectPe await self.telegram_bot.send_message( f'✅ Канал "{input.chat_title}" был активирован.\n\nВсе необходимые права боту предоставлены!', - input.user_telegram_id, + user.telegram_user.telegram_id, ) log.info(f'Project channel {input.telegram_id} reactivated - all permissions granted') return @@ -49,7 +52,7 @@ async def update_project_permissions(self: 'Usecase', input: dto.UpdateProjectPe f'⚠️ Канал "{input.chat_title}" был деактивирован.\n\n' f'Боту убрали необходимые права:\n{missed_permissions}' ), - input.user_telegram_id, + user.telegram_user.telegram_id, ) log.warning( 'Project channel %s deactivated due to missing permissions: %s', diff --git a/src/usecase/subscription/handle_subscription.py b/src/usecase/subscription/handle_subscription.py index 918c6f3..4341b08 100644 --- a/src/usecase/subscription/handle_subscription.py +++ b/src/usecase/subscription/handle_subscription.py @@ -22,20 +22,20 @@ async def handle_subscription( log.warning('Placement not found for invite_link: %s', invite_link) return - subscriber = await self.database.get_user(telegram_id=user_telegram_id) + subscriber = await self.database.get_telegram_user(telegram_id=user_telegram_id) if not subscriber: - subscriber = domain.User( + subscriber = domain.TelegramUser( telegram_id=user_telegram_id, username=username, first_name=first_name, last_name=last_name, ) - await self.database.create_user(subscriber) + await self.database.create_telegram_user(subscriber) else: subscriber.username = username or subscriber.username subscriber.first_name = first_name or subscriber.first_name subscriber.last_name = last_name or subscriber.last_name - await self.database.update_user(subscriber) + await self.database.update_telegram_user(subscriber) active_subscription = await self.database.get_active_subscription_by_subscriber_and_project( subscriber.id, placement.project_id @@ -74,7 +74,7 @@ async def handle_subscription( subscription = domain.Subscription( placement_id=placement.id, - subscriber_id=subscriber.id, + telegram_user_id=subscriber.id, invite_link=invite_link, ) await self.database.create_subscription(subscription) diff --git a/src/usecase/subscription/handle_unsubscription.py b/src/usecase/subscription/handle_unsubscription.py index 1b7b8b1..0f25a46 100644 --- a/src/usecase/subscription/handle_unsubscription.py +++ b/src/usecase/subscription/handle_unsubscription.py @@ -12,7 +12,7 @@ log = logging.getLogger(__name__) async def handle_unsubscription(self: 'Usecase', user_telegram_id: int, channel_telegram_id: int) -> None: - subscriber = await self.database.get_user(telegram_id=user_telegram_id) + subscriber = await self.database.get_telegram_user(telegram_id=user_telegram_id) if not subscriber: log.warning('Subscriber not found for telegram_id: %s', user_telegram_id) return diff --git a/src/usecase/workspace/create_workspace_invite.py b/src/usecase/workspace/create_workspace_invite.py index ecaf4a9..8ef0855 100644 --- a/src/usecase/workspace/create_workspace_invite.py +++ b/src/usecase/workspace/create_workspace_invite.py @@ -54,6 +54,11 @@ async def create_workspace_invite( if invited_by_user is None: raise domain.UserNotFound(user_id) + if invited_user.telegram_user is None: + raise domain.UserNotFound(invited_user.id) + if invited_by_user.telegram_user is None: + raise domain.UserNotFound(invited_by_user.id) + invite.user = invited_user invite.invited_by = invited_by_user @@ -68,7 +73,7 @@ async def create_workspace_invite( ) await self.telegram_bot.send_message_with_inline_keyboard( invite_message, - chat_id=invited_user.telegram_id, + chat_id=invited_user.telegram_user.telegram_id, buttons=[[accept_button]], ) diff --git a/src/usecase/workspace/tg_accept_workspace_invite.py b/src/usecase/workspace/tg_accept_workspace_invite.py index 063ca25..7c95f41 100644 --- a/src/usecase/workspace/tg_accept_workspace_invite.py +++ b/src/usecase/workspace/tg_accept_workspace_invite.py @@ -24,6 +24,9 @@ async def tg_accept_workspace_invite( if not user: await self.telegram_bot.send_message('❌ Вы не авторизованы. Используйте /start для входа.', chat_id) return + if user.telegram_user is None: + await self.telegram_bot.send_message('❌ Ваш Telegram профиль не найден. Авторизуйтесь заново.', chat_id) + return parts = callback_data.split(':', 1) if len(parts) != 2 or parts[0] != INVITE_ACCEPT_CALLBACK_PREFIX: @@ -67,7 +70,7 @@ async def tg_accept_workspace_invite( await self.telegram_bot.send_message( f'✅ Вы присоединились к рабочему пространству «{workspace_name}».\n\n' 'Администратор сможет выдать вам необходимые права в веб-интерфейсе.', - chat_id, + user.telegram_user.telegram_id, ) if message_id: