Add for leave family
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -154,3 +154,10 @@ export class RespondFamilyInviteDto {
|
||||
accept!: boolean;
|
||||
}
|
||||
|
||||
export class LeaveFamilyGroupDto {
|
||||
@ApiPropertyOptional({ description: 'ID нового главы семьи (обязателен для создателя при наличии других участников)' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'ID нового главы семьи должен быть строкой' })
|
||||
newOwnerUserId?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) : 'Статус неизвестен'}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user