From 886b7e9ade29dd7ab5070787575960b503617f15 Mon Sep 17 00:00:00 2001 From: lendry Date: Fri, 26 Jun 2026 23:41:20 +0300 Subject: [PATCH] Add for leave family --- .../src/controllers/family.controller.ts | 19 +- apps/api-gateway/src/dto/identity.dto.ts | 7 + .../components/family/family-group-view.tsx | 202 ++++++++++++++- apps/frontend/lib/api.ts | 13 + .../src/domain/auth-grpc.controller.ts | 5 + apps/sso-core/src/domain/chat.service.ts | 10 +- apps/sso-core/src/domain/family.service.ts | 240 +++++++++++++++++- shared/proto/chat.proto | 2 + shared/proto/identity.proto | 12 + 9 files changed, 489 insertions(+), 21 deletions(-) diff --git a/apps/api-gateway/src/controllers/family.controller.ts b/apps/api-gateway/src/controllers/family.controller.ts index dde513a..686d311 100644 --- a/apps/api-gateway/src/controllers/family.controller.ts +++ b/apps/api-gateway/src/controllers/family.controller.ts @@ -5,7 +5,7 @@ import { firstValueFrom } from 'rxjs'; import { map } from 'rxjs/operators'; import { CoreGrpcService } from '../core-grpc.service'; import { getAuthorizedUserId } from '../document-access'; -import { AddFamilyMemberDto, CreateFamilyGroupDto, RespondFamilyInviteDto, SendFamilyInviteDto, UpdateFamilyGroupDto } from '../dto/identity.dto'; +import { AddFamilyMemberDto, CreateFamilyGroupDto, LeaveFamilyGroupDto, RespondFamilyInviteDto, SendFamilyInviteDto, UpdateFamilyGroupDto } from '../dto/identity.dto'; @ApiTags('Семья') @ApiBearerAuth() @@ -91,6 +91,23 @@ export class FamilyController { return firstValueFrom(this.core.family.RemoveFamilyMember({ requesterId, memberId })); } + @Post('groups/:groupId/leave') + @ApiOperation({ summary: 'Покинуть семейную группу' }) + async leaveGroup( + @Headers('authorization') authorization: string | undefined, + @Param('groupId') groupId: string, + @Body() dto: LeaveFamilyGroupDto + ) { + const requesterId = await this.auth(authorization); + return firstValueFrom( + this.core.family.LeaveFamilyGroup({ + requesterId, + groupId, + newOwnerUserId: dto.newOwnerUserId + }) + ); + } + @Get('groups/:groupId/invite-search') @ApiOperation({ summary: 'Поиск пользователей для приглашения в семью' }) async searchInviteUsers( diff --git a/apps/api-gateway/src/dto/identity.dto.ts b/apps/api-gateway/src/dto/identity.dto.ts index be4a589..0bcb37f 100644 --- a/apps/api-gateway/src/dto/identity.dto.ts +++ b/apps/api-gateway/src/dto/identity.dto.ts @@ -154,3 +154,10 @@ export class RespondFamilyInviteDto { accept!: boolean; } +export class LeaveFamilyGroupDto { + @ApiPropertyOptional({ description: 'ID нового главы семьи (обязателен для создателя при наличии других участников)' }) + @IsOptional() + @IsString({ message: 'ID нового главы семьи должен быть строкой' }) + newOwnerUserId?: string; +} + diff --git a/apps/frontend/components/family/family-group-view.tsx b/apps/frontend/components/family/family-group-view.tsx index e1c82d2..28a1f2f 100644 --- a/apps/frontend/components/family/family-group-view.tsx +++ b/apps/frontend/components/family/family-group-view.tsx @@ -12,6 +12,7 @@ import { FileText, ImageIcon, Loader2, + LogOut, Mic, Paperclip, Pin, @@ -84,6 +85,7 @@ import { fetchFamilyPresence, forwardChatMessages, getApiErrorMessage, + leaveFamilyGroup, markChatRoomRead, removeChatRoomMember, removeFamilyMember, @@ -278,6 +280,9 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { const [editingMessageId, setEditingMessageId] = useState(null); const [presenceMembers, setPresenceMembers] = useState([]); const [deleteFamilyDialogOpen, setDeleteFamilyDialogOpen] = useState(false); + const [leaveFamilyDialogOpen, setLeaveFamilyDialogOpen] = useState(false); + const [leavingFamily, setLeavingFamily] = useState(false); + const [selectedNewOwnerUserId, setSelectedNewOwnerUserId] = useState(null); const [deletingFamily, setDeletingFamily] = useState(false); const [deleteMessagesDialogOpen, setDeleteMessagesDialogOpen] = useState(false); const [pendingDeleteMessageIds, setPendingDeleteMessageIds] = useState([]); @@ -381,6 +386,16 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { ); const myMembership = activeRoom?.members.find((member) => member.userId === user?.id); const isFamilyOwner = group?.ownerId === user?.id; + const humanMembers = useMemo( + () => (group?.members ?? []).filter((member) => !member.isBot && member.role !== 'bot'), + [group?.members] + ); + const otherHumanMembers = useMemo( + () => humanMembers.filter((member) => member.userId !== user?.id), + [humanMembers, user?.id] + ); + const ownerMustTransfer = Boolean(isFamilyOwner && otherHumanMembers.length > 0); + const ownerLeaveDeletesFamily = Boolean(isFamilyOwner && otherHumanMembers.length === 0); const isChatCreator = activeRoom?.createdById === user?.id; const canManageChatMembers = Boolean(isFamilyOwner || isChatCreator || activeRoomIsBot); const canManageActiveBot = Boolean( @@ -609,6 +624,31 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { if (event.type === 'family_group_deleted' && payload?.groupId === groupId) { showToast('Семья была удалена'); router.push('/family'); + return; + } + + if (event.type === 'family_left_self' && payload?.groupId === groupId) { + router.push('/family'); + return; + } + + if (event.type === 'family_member_left' && payload?.groupId === groupId) { + const userName = typeof payload?.userName === 'string' ? payload.userName : 'Участник'; + showToast(`${userName} покинул(а) семью`); + void loadGroup(); + return; + } + + if (event.type === 'family_member_removed' && payload?.groupId === groupId) { + const userName = typeof payload?.userName === 'string' ? payload.userName : 'Участник'; + showToast(`${userName} удалён(а) из семьи`); + void loadGroup(); + return; + } + + if (event.type === 'family_ownership_transferred' && payload?.groupId === groupId) { + showToast('Вы стали главой семьи'); + void loadGroup(); } }); }, [ @@ -821,12 +861,6 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { if (!token) return; try { await removeFamilyMember(memberId, token); - const removedMember = group?.members?.find((item) => item.id === memberId); - if (removedMember?.userId === user?.id) { - showToast('Вы вышли из семьи'); - router.push('/family'); - return; - } await loadGroup(); setMembersOpen(false); setFamilyMembersOpen(false); @@ -836,6 +870,35 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { } } + function openLeaveFamilyDialog() { + setSelectedNewOwnerUserId(otherHumanMembers[0]?.userId ?? null); + setLeaveFamilyDialogOpen(true); + } + + async function confirmLeaveFamily() { + if (!token) return; + if (ownerMustTransfer && !selectedNewOwnerUserId) { + showToast('Выберите нового главу семьи'); + return; + } + setLeavingFamily(true); + try { + const result = await leaveFamilyGroup( + groupId, + token, + ownerMustTransfer ? selectedNewOwnerUserId ?? undefined : undefined + ); + setLeaveFamilyDialogOpen(false); + setFamilyMembersOpen(false); + showToast(result.deleted ? 'Семья удалена' : 'Вы покинули семью'); + router.push('/family'); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось покинуть семью') ?? 'Ошибка'); + } finally { + setLeavingFamily(false); + } + } + async function createSecretChatWithMember(memberUserId: string) { if (!token) return; try { @@ -1456,6 +1519,11 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {

{user ? roomDisplayLabel(room, user.id) : room.name} + {room.peerLeftFamily ? ( + + не в семье + + ) : null} {room.isPinned ? : null}

{room.lastMessage ? ( @@ -1528,9 +1596,19 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { {activeRoomIsBot ? ( <>🤖 Бот · {activeRoom.members.length} участников ) : activeRoomIsE2E ? ( - <>🔒 E2E · секретный чат + <> + 🔒 E2E · секретный чат + {activeRoom.peerLeftFamily ? ( + · собеседник не в семье + ) : null} + ) : activeRoom.type === 'DIRECT' ? ( - <>Личный чат · {activeRoom.members.length} участников + <> + Личный чат · {activeRoom.members.length} участников + {activeRoom.peerLeftFamily ? ( + · собеседник не в семье + ) : null} + ) : ( <> {onlineCount > 0 ? ( @@ -2220,8 +2298,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
{(group?.members ?? []).map((member) => { const isSelf = member.userId === user?.id; - const canRemove = - member.role !== 'owner' && (isFamilyOwner || isSelf); + const canKick = !isSelf && isFamilyOwner && member.role !== 'owner' && !member.isBot; const presence = presenceByUserId.get(member.userId); return ( @@ -2272,7 +2349,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { 🔒 E2E ) : null} - {canRemove ? ( + {canKick ? ( ) : null}
@@ -2296,6 +2373,17 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { Пригласить в семью + {isFamilyOwner ? ( + ); + })} +
+ + ) : ownerLeaveDeletesFamily ? ( +

+ В семье больше нет других участников. Семья и все чаты будут полностью удалены без возможности + восстановления. +

+ ) : ( +

+ Семья исчезнет из вашего профиля вместе со всеми чатами. У остальных участников переписка с вами + сохранится с пометкой, что вы больше не состоите в семье. +

+ )} +
+ + +
+ + + @@ -2366,7 +2535,14 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { className="h-10 w-10" />
-

{member.displayName}

+

+ {member.displayName} + {member.inFamily === false ? ( + + не в семье + + ) : null} +

{memberRoleLabel(member)}

{presence ? formatPresenceStatus(presence) : 'Статус неизвестен'} diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index d7e64af..2c2ff8b 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -859,6 +859,7 @@ export interface ChatRoomMember { pinnedAt?: string; familyRole?: string; isChatCreator?: boolean; + inFamily?: boolean; } export interface ChatRoom { @@ -874,6 +875,7 @@ export interface ChatRoom { updatedAt: string; isPinned?: boolean; pinnedAt?: string; + peerLeftFamily?: boolean; members: ChatRoomMember[]; lastMessage?: ChatMessage; } @@ -978,6 +980,17 @@ export async function removeFamilyMember(memberId: string, token?: string | null return apiFetch<{ count: number }>(`/family/members/${memberId}`, { method: 'DELETE' }, token); } +export async function leaveFamilyGroup(groupId: string, token?: string | null, newOwnerUserId?: string) { + return apiFetch<{ count: number; deleted?: boolean }>( + `/family/groups/${groupId}/leave`, + { + method: 'POST', + body: JSON.stringify(newOwnerUserId ? { newOwnerUserId } : {}) + }, + token + ); +} + export async function fetchChatMessages(roomId: string, token?: string | null, beforeMessageId?: string) { const query = beforeMessageId ? `?beforeMessageId=${beforeMessageId}` : ''; return apiFetch<{ messages?: ChatMessage[] }>(`/chat/rooms/${roomId}/messages${query}`, {}, token); diff --git a/apps/sso-core/src/domain/auth-grpc.controller.ts b/apps/sso-core/src/domain/auth-grpc.controller.ts index 33ad04d..88cc1d6 100644 --- a/apps/sso-core/src/domain/auth-grpc.controller.ts +++ b/apps/sso-core/src/domain/auth-grpc.controller.ts @@ -907,6 +907,11 @@ export class AuthGrpcController { return this.family.removeMember(command.requesterId, command.memberId); } + @GrpcMethod('FamilyService', 'LeaveFamilyGroup') + leaveFamilyGroup(command: { requesterId: string; groupId: string; newOwnerUserId?: string }) { + return this.family.leaveFamily(command.requesterId, command.groupId, command.newOwnerUserId); + } + @GrpcMethod('FamilyService', 'SendFamilyInvite') async sendFamilyInvite(command: { requesterId: string; groupId: string; target?: string; inviteeUserId?: string }) { const invite = await this.family.sendInvite(command.requesterId, command.groupId, { diff --git a/apps/sso-core/src/domain/chat.service.ts b/apps/sso-core/src/domain/chat.service.ts index ab8a55c..e0cbff4 100644 --- a/apps/sso-core/src/domain/chat.service.ts +++ b/apps/sso-core/src/domain/chat.service.ts @@ -940,12 +940,16 @@ export class ChatService { viewerId?: string ) { const viewerMembership = viewerId ? room.members.find((member) => member.userId === viewerId) : undefined; + const activeFamilyUserIds = new Set(familyRoleByUserId.keys()); const peerMember = room.type === 'BOT' ? room.members.find((member) => Boolean(member.user.linkedBotId)) : viewerId && (room.type === 'DIRECT' || room.type === 'E2E') ? room.members.find((member) => member.userId !== viewerId) : undefined; + const peerLeftFamily = Boolean( + peerMember && !activeFamilyUserIds.has(peerMember.userId) && !peerMember.user.linkedBotId + ); return { id: room.id, @@ -960,6 +964,7 @@ export class ChatService { updatedAt: room.updatedAt.toISOString(), isPinned: Boolean(viewerMembership?.pinnedAt), pinnedAt: viewerMembership?.pinnedAt?.toISOString(), + peerLeftFamily, members: room.members.map((member) => ({ id: member.id, userId: member.userId, @@ -970,7 +975,10 @@ export class ChatService { notificationsMuted: member.notificationsMuted, isPinned: Boolean(member.pinnedAt), pinnedAt: member.pinnedAt?.toISOString(), - familyRole: familyRoleByUserId.get(member.userId) ?? 'member', + inFamily: activeFamilyUserIds.has(member.userId), + familyRole: activeFamilyUserIds.has(member.userId) + ? (familyRoleByUserId.get(member.userId) ?? 'member') + : 'member', isChatCreator: room.createdById === member.userId })), lastMessage diff --git a/apps/sso-core/src/domain/family.service.ts b/apps/sso-core/src/domain/family.service.ts index 9277ddc..e493e6e 100644 --- a/apps/sso-core/src/domain/family.service.ts +++ b/apps/sso-core/src/domain/family.service.ts @@ -212,12 +212,76 @@ export class FamilyService { for (const member of memberships) { - await this.removeMember(userId, member.id); + await this.leaveFamily(userId, member.groupId); } } + async leaveFamily(requesterId: string, groupId: string, newOwnerUserId?: string) { + + const group = await this.getGroupWithMembers(groupId); + + this.assertMember(group, requesterId); + + const member = group.members.find((item) => item.userId === requesterId); + + if (!member) { + + throw new NotFoundException('Участник не найден'); + + } + + const isOwner = group.ownerId === requesterId; + + const otherHumans = this.getHumanMembers(group.members).filter((item) => item.userId !== requesterId); + + if (isOwner && otherHumans.length === 0) { + + await this.destroyGroupWithCleanup(group); + + return { count: 1, deleted: true }; + + } + + if (isOwner && otherHumans.length > 0) { + + if (!newOwnerUserId) { + + throw new BadRequestException('Перед выходом передайте управление семьёй другому участнику'); + + } + + if (!otherHumans.some((item) => item.userId === newOwnerUserId)) { + + throw new BadRequestException('Новый глава семьи должен быть участником семьи'); + + } + + await this.transferOwnership(group, requesterId, newOwnerUserId); + + const updatedGroup = await this.getGroupWithMembers(groupId); + + const updatedMember = updatedGroup.members.find((item) => item.userId === requesterId); + + if (!updatedMember) { + + throw new NotFoundException('Участник не найден'); + + } + + await this.executeMemberLeave(updatedGroup, updatedMember, { voluntary: true }); + + return { count: 1, deleted: false }; + + } + + await this.executeMemberLeave(group, member, { voluntary: true }); + + return { count: 1, deleted: false }; + + } + private async destroyGroupWithCleanup(group: { id: string; @@ -399,7 +463,7 @@ export class FamilyService { where: { id: memberId }, - include: { group: { include: { members: true } } } + include: { group: { include: { members: { include: this.memberUserInclude() } } } } }); @@ -413,7 +477,13 @@ export class FamilyService { const isOwner = member.group.ownerId === requesterId; - if (!isSelf && !isOwner) { + if (isSelf) { + + throw new BadRequestException('Для выхода из семьи используйте соответствующую функцию'); + + } + + if (!isOwner) { throw new ForbiddenException('Недостаточно прав для удаления участника'); @@ -425,17 +495,175 @@ export class FamilyService { } + await this.executeMemberLeave(member.group, member, { voluntary: false }); + return { count: 1 }; + + } + + private async transferOwnership( + + group: { + + id: string; + + name: string; + + members: Array<{ userId: string; user?: { displayName: string } }>; + + }, + + fromUserId: string, + + toUserId: string + + ) { await this.prisma.$transaction(async (tx) => { - await tx.chatRoomMember.deleteMany({ where: { userId: member.userId, room: { groupId: member.groupId } } }); + await tx.familyGroup.update({ where: { id: group.id }, data: { ownerId: toUserId } }); - await tx.familyMember.delete({ where: { id: memberId } }); + await tx.familyMember.updateMany({ where: { groupId: group.id, userId: fromUserId }, data: { role: 'member' } }); + + await tx.familyMember.updateMany({ where: { groupId: group.id, userId: toUserId }, data: { role: 'owner' } }); }); - return { count: 1 }; + const oldOwnerName = group.members.find((item) => item.userId === fromUserId)?.user?.displayName ?? 'Участник'; + + const title = 'Вы стали главой семьи'; + + const body = `${oldOwnerName} передал(а) вам управление семьёй «${group.name}»`; + + await this.notifications.create( + + toUserId, + + 'family_ownership_transferred', + + title, + + body, + + { groupId: group.id, groupName: group.name, previousOwnerId: fromUserId } + + ); + + await this.notifications.publishRealtime( + + toUserId, + + 'family_ownership_transferred', + + title, + + body, + + { groupId: group.id, groupName: group.name, previousOwnerId: fromUserId } + + ); + + } + + private async executeMemberLeave( + + group: { + + id: string; + + name: string; + + members: Array<{ id: string; userId: string; role: string; user?: { displayName: string; linkedBotId?: string | null } }>; + + }, + + member: { id: string; userId: string; user?: { displayName: string } }, + + options: { voluntary: boolean } + + ) { + + const displayName = member.user?.displayName ?? (await this.loadMemberUser(member.userId))?.displayName ?? 'Участник'; + + await this.prisma.$transaction(async (tx) => { + + await tx.chatRoomMember.deleteMany({ where: { userId: member.userId, room: { groupId: group.id } } }); + + await tx.familyMember.delete({ where: { id: member.id } }); + + }); + + const recipients = group.members.filter( + + (item) => item.userId !== member.userId && item.role !== 'bot' && !item.user?.linkedBotId + + ); + + const eventType = options.voluntary ? 'family_member_left' : 'family_member_removed'; + + const title = options.voluntary ? 'Участник покинул семью' : 'Участник удалён из семьи'; + + const body = options.voluntary + + ? `${displayName} покинул(а) семью «${group.name}»` + + : `${displayName} был(а) удалён(а) из семьи «${group.name}»`; + + for (const recipient of recipients) { + + await this.notifications.create( + + recipient.userId, + + eventType, + + title, + + body, + + { groupId: group.id, groupName: group.name, userId: member.userId, userName: displayName } + + ); + + await this.notifications.publishRealtime( + + recipient.userId, + + eventType, + + title, + + body, + + { groupId: group.id, groupName: group.name, userId: member.userId, userName: displayName } + + ); + + } + + await this.notifications.publishRealtime( + + member.userId, + + 'family_left_self', + + 'Вы покинули семью', + + `Вы покинули семью «${group.name}»`, + + { groupId: group.id, groupName: group.name } + + ); + + } + + private getHumanMembers( + + members: Array<{ userId: string; role: string; user?: { linkedBotId?: string | null } }> + + ) { + + return members.filter((member) => member.role !== 'bot' && !member.user?.linkedBotId); } diff --git a/shared/proto/chat.proto b/shared/proto/chat.proto index 95e24e0..0a71a63 100644 --- a/shared/proto/chat.proto +++ b/shared/proto/chat.proto @@ -159,6 +159,7 @@ message ChatRoomMemberResponse { bool isChatCreator = 7; bool isPinned = 8; optional string pinnedAt = 9; + bool inFamily = 10; } message ChatRoomResponse { @@ -176,6 +177,7 @@ message ChatRoomResponse { optional bool isE2E = 12; bool isPinned = 13; optional string pinnedAt = 14; + bool peerLeftFamily = 15; } message PollOptionResponse { diff --git a/shared/proto/identity.proto b/shared/proto/identity.proto index 287f9d4..49e881c 100644 --- a/shared/proto/identity.proto +++ b/shared/proto/identity.proto @@ -34,6 +34,7 @@ service FamilyService { rpc DeleteFamilyGroup (DeleteFamilyGroupRequest) returns (MutationResponse); rpc AddFamilyMember (FamilyMemberRequest) returns (FamilyMemberResponse); rpc RemoveFamilyMember (RemoveFamilyMemberRequest) returns (MutationResponse); + rpc LeaveFamilyGroup (LeaveFamilyGroupRequest) returns (LeaveFamilyGroupResponse); rpc SendFamilyInvite (SendFamilyInviteRequest) returns (FamilyInviteResponse); rpc SearchFamilyInviteUsers (SearchFamilyInviteUsersRequest) returns (SearchFamilyInviteUsersResponse); rpc RespondFamilyInvite (RespondFamilyInviteRequest) returns (FamilyInviteResponse); @@ -241,6 +242,17 @@ message RemoveFamilyMemberRequest { string memberId = 2; } +message LeaveFamilyGroupRequest { + string requesterId = 1; + string groupId = 2; + optional string newOwnerUserId = 3; +} + +message LeaveFamilyGroupResponse { + int32 count = 1; + bool deleted = 2; +} + message SendFamilyInviteRequest { string requesterId = 1; string groupId = 2;