Add for leave family

This commit is contained in:
lendry
2026-06-26 23:41:20 +03:00
parent f1bba24faa
commit 886b7e9ade
9 changed files with 489 additions and 21 deletions

View File

@@ -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(

View File

@@ -154,3 +154,10 @@ export class RespondFamilyInviteDto {
accept!: boolean;
}
export class LeaveFamilyGroupDto {
@ApiPropertyOptional({ description: 'ID нового главы семьи (обязателен для создателя при наличии других участников)' })
@IsOptional()
@IsString({ message: 'ID нового главы семьи должен быть строкой' })
newOwnerUserId?: string;
}

View File

@@ -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<string | null>(null);
const [presenceMembers, setPresenceMembers] = useState<FamilyPresenceMember[]>([]);
const [deleteFamilyDialogOpen, setDeleteFamilyDialogOpen] = useState(false);
const [leaveFamilyDialogOpen, setLeaveFamilyDialogOpen] = useState(false);
const [leavingFamily, setLeavingFamily] = useState(false);
const [selectedNewOwnerUserId, setSelectedNewOwnerUserId] = useState<string | null>(null);
const [deletingFamily, setDeletingFamily] = useState(false);
const [deleteMessagesDialogOpen, setDeleteMessagesDialogOpen] = useState(false);
const [pendingDeleteMessageIds, setPendingDeleteMessageIds] = useState<string[]>([]);
@@ -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 }) {
<div className="flex items-center justify-between gap-2">
<p className="flex min-w-0 items-center gap-1 truncate font-medium">
<span className="truncate">{user ? roomDisplayLabel(room, user.id) : room.name}</span>
{room.peerLeftFamily ? (
<span className="shrink-0 rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
не в семье
</span>
) : null}
{room.isPinned ? <Pin className="h-3.5 w-3.5 shrink-0 fill-[#8a8f9e] text-[#8a8f9e]" /> : null}
</p>
{room.lastMessage ? (
@@ -1528,9 +1596,19 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
{activeRoomIsBot ? (
<>🤖 Бот · {activeRoom.members.length} участников</>
) : activeRoomIsE2E ? (
<>🔒 E2E · секретный чат</>
<>
🔒 E2E · секретный чат
{activeRoom.peerLeftFamily ? (
<span className="ml-1 text-amber-600">· собеседник не в семье</span>
) : null}
</>
) : activeRoom.type === 'DIRECT' ? (
<>Личный чат · {activeRoom.members.length} участников</>
<>
Личный чат · {activeRoom.members.length} участников
{activeRoom.peerLeftFamily ? (
<span className="ml-1 text-amber-600">· собеседник не в семье</span>
) : null}
</>
) : (
<>
{onlineCount > 0 ? (
@@ -2220,8 +2298,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
<div className="mt-3 max-h-[420px] space-y-2 overflow-y-auto">
{(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
</Button>
) : null}
{canRemove ? (
{canKick ? (
<Button
size="sm"
variant="ghost"
@@ -2282,7 +2359,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
void handleRemoveFromFamily(member.id);
}}
>
{isSelf ? 'Выйти' : 'Удалить'}
Удалить
</Button>
) : null}
</div>
@@ -2296,6 +2373,17 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
<UserPlus className="mr-2 h-4 w-4" />
Пригласить в семью
</Button>
<Button
className="mt-2 w-full rounded-xl"
variant="secondary"
onClick={() => {
setFamilyMembersOpen(false);
openLeaveFamilyDialog();
}}
>
<LogOut className="mr-2 h-4 w-4" />
Покинуть семью
</Button>
{isFamilyOwner ? (
<Button
className="mt-2 w-full rounded-xl bg-red-600 hover:bg-red-700"
@@ -2311,6 +2399,87 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
</DialogContent>
</Dialog>
<Dialog open={leaveFamilyDialogOpen} onOpenChange={setLeaveFamilyDialogOpen}>
<DialogContent className="rounded-[28px] sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Покинуть семью «{group?.name}»?</DialogTitle>
</DialogHeader>
{ownerMustTransfer ? (
<>
<p className="text-sm leading-relaxed text-[#667085]">
Вы создатель семьи. Перед выходом передайте управление другому участнику он станет новым главой
семьи.
</p>
<div className="mt-4 max-h-[280px] space-y-2 overflow-y-auto">
{otherHumanMembers.map((member) => {
const selected = selectedNewOwnerUserId === member.userId;
return (
<button
key={member.id}
type="button"
onClick={() => setSelectedNewOwnerUserId(member.userId)}
className={cn(
'flex w-full items-center gap-3 rounded-2xl px-3 py-3 text-left transition',
selected ? 'bg-[#3390ec]/10 ring-2 ring-[#3390ec]/40' : 'bg-[#f4f5f8] hover:bg-[#eceef4]'
)}
>
<AvatarWithPresence
userId={member.userId}
displayName={member.displayName}
hasAvatar={member.hasAvatar}
token={token}
isVerified={member.isVerified}
verificationIcon={member.verificationIcon}
className="h-10 w-10"
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{member.displayName}</p>
<p className="text-xs text-[#667085]">{familyMemberRoleLabel(member.role)}</p>
</div>
{selected ? <Check className="h-5 w-5 shrink-0 text-[#3390ec]" /> : null}
</button>
);
})}
</div>
</>
) : ownerLeaveDeletesFamily ? (
<p className="text-sm leading-relaxed text-[#667085]">
В семье больше нет других участников. Семья и все чаты будут полностью удалены без возможности
восстановления.
</p>
) : (
<p className="text-sm leading-relaxed text-[#667085]">
Семья исчезнет из вашего профиля вместе со всеми чатами. У остальных участников переписка с вами
сохранится с пометкой, что вы больше не состоите в семье.
</p>
)}
<div className="mt-6 flex gap-3">
<Button
variant="secondary"
className="flex-1"
disabled={leavingFamily}
onClick={() => setLeaveFamilyDialogOpen(false)}
>
Отмена
</Button>
<Button
className="flex-1 bg-red-600 hover:bg-red-700"
disabled={leavingFamily || (ownerMustTransfer && !selectedNewOwnerUserId)}
onClick={() => void confirmLeaveFamily()}
>
{leavingFamily ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Выходим...
</>
) : (
'Покинуть семью'
)}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={deleteFamilyDialogOpen} onOpenChange={setDeleteFamilyDialogOpen}>
<DialogContent className="rounded-[28px] sm:max-w-[440px]">
<DialogHeader>
@@ -2366,7 +2535,14 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
className="h-10 w-10"
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{member.displayName}</p>
<p className="truncate font-medium">
{member.displayName}
{member.inFamily === false ? (
<span className="ml-1.5 rounded-md bg-[#eceef4] px-1.5 py-0.5 text-[10px] font-medium text-[#667085]">
не в семье
</span>
) : null}
</p>
<p className="text-xs text-[#667085]">{memberRoleLabel(member)}</p>
<p className={cn('text-xs', presence?.online ? 'font-medium text-emerald-600' : 'text-[#a8adbc]')}>
{presence ? formatPresenceStatus(presence) : 'Статус неизвестен'}

View File

@@ -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);

View File

@@ -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, {

View File

@@ -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

View File

@@ -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);
}