Add for leave family
This commit is contained in:
@@ -5,7 +5,7 @@ import { firstValueFrom } from 'rxjs';
|
|||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { getAuthorizedUserId } from '../document-access';
|
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('Семья')
|
@ApiTags('Семья')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -91,6 +91,23 @@ export class FamilyController {
|
|||||||
return firstValueFrom(this.core.family.RemoveFamilyMember({ requesterId, memberId }));
|
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')
|
@Get('groups/:groupId/invite-search')
|
||||||
@ApiOperation({ summary: 'Поиск пользователей для приглашения в семью' })
|
@ApiOperation({ summary: 'Поиск пользователей для приглашения в семью' })
|
||||||
async searchInviteUsers(
|
async searchInviteUsers(
|
||||||
|
|||||||
@@ -154,3 +154,10 @@ export class RespondFamilyInviteDto {
|
|||||||
accept!: boolean;
|
accept!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class LeaveFamilyGroupDto {
|
||||||
|
@ApiPropertyOptional({ description: 'ID нового главы семьи (обязателен для создателя при наличии других участников)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'ID нового главы семьи должен быть строкой' })
|
||||||
|
newOwnerUserId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
LogOut,
|
||||||
Mic,
|
Mic,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Pin,
|
Pin,
|
||||||
@@ -84,6 +85,7 @@ import {
|
|||||||
fetchFamilyPresence,
|
fetchFamilyPresence,
|
||||||
forwardChatMessages,
|
forwardChatMessages,
|
||||||
getApiErrorMessage,
|
getApiErrorMessage,
|
||||||
|
leaveFamilyGroup,
|
||||||
markChatRoomRead,
|
markChatRoomRead,
|
||||||
removeChatRoomMember,
|
removeChatRoomMember,
|
||||||
removeFamilyMember,
|
removeFamilyMember,
|
||||||
@@ -278,6 +280,9 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
||||||
const [presenceMembers, setPresenceMembers] = useState<FamilyPresenceMember[]>([]);
|
const [presenceMembers, setPresenceMembers] = useState<FamilyPresenceMember[]>([]);
|
||||||
const [deleteFamilyDialogOpen, setDeleteFamilyDialogOpen] = useState(false);
|
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 [deletingFamily, setDeletingFamily] = useState(false);
|
||||||
const [deleteMessagesDialogOpen, setDeleteMessagesDialogOpen] = useState(false);
|
const [deleteMessagesDialogOpen, setDeleteMessagesDialogOpen] = useState(false);
|
||||||
const [pendingDeleteMessageIds, setPendingDeleteMessageIds] = useState<string[]>([]);
|
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 myMembership = activeRoom?.members.find((member) => member.userId === user?.id);
|
||||||
const isFamilyOwner = group?.ownerId === 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 isChatCreator = activeRoom?.createdById === user?.id;
|
||||||
const canManageChatMembers = Boolean(isFamilyOwner || isChatCreator || activeRoomIsBot);
|
const canManageChatMembers = Boolean(isFamilyOwner || isChatCreator || activeRoomIsBot);
|
||||||
const canManageActiveBot = Boolean(
|
const canManageActiveBot = Boolean(
|
||||||
@@ -609,6 +624,31 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
if (event.type === 'family_group_deleted' && payload?.groupId === groupId) {
|
if (event.type === 'family_group_deleted' && payload?.groupId === groupId) {
|
||||||
showToast('Семья была удалена');
|
showToast('Семья была удалена');
|
||||||
router.push('/family');
|
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;
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
await removeFamilyMember(memberId, token);
|
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();
|
await loadGroup();
|
||||||
setMembersOpen(false);
|
setMembersOpen(false);
|
||||||
setFamilyMembersOpen(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) {
|
async function createSecretChatWithMember(memberUserId: string) {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
@@ -1456,6 +1519,11 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="flex min-w-0 items-center gap-1 truncate font-medium">
|
<p className="flex min-w-0 items-center gap-1 truncate font-medium">
|
||||||
<span className="truncate">{user ? roomDisplayLabel(room, user.id) : room.name}</span>
|
<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}
|
{room.isPinned ? <Pin className="h-3.5 w-3.5 shrink-0 fill-[#8a8f9e] text-[#8a8f9e]" /> : null}
|
||||||
</p>
|
</p>
|
||||||
{room.lastMessage ? (
|
{room.lastMessage ? (
|
||||||
@@ -1528,9 +1596,19 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
{activeRoomIsBot ? (
|
{activeRoomIsBot ? (
|
||||||
<>🤖 Бот · {activeRoom.members.length} участников</>
|
<>🤖 Бот · {activeRoom.members.length} участников</>
|
||||||
) : activeRoomIsE2E ? (
|
) : activeRoomIsE2E ? (
|
||||||
<>🔒 E2E · секретный чат</>
|
<>
|
||||||
|
🔒 E2E · секретный чат
|
||||||
|
{activeRoom.peerLeftFamily ? (
|
||||||
|
<span className="ml-1 text-amber-600">· собеседник не в семье</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
) : activeRoom.type === 'DIRECT' ? (
|
) : activeRoom.type === 'DIRECT' ? (
|
||||||
<>Личный чат · {activeRoom.members.length} участников</>
|
<>
|
||||||
|
Личный чат · {activeRoom.members.length} участников
|
||||||
|
{activeRoom.peerLeftFamily ? (
|
||||||
|
<span className="ml-1 text-amber-600">· собеседник не в семье</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{onlineCount > 0 ? (
|
{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">
|
<div className="mt-3 max-h-[420px] space-y-2 overflow-y-auto">
|
||||||
{(group?.members ?? []).map((member) => {
|
{(group?.members ?? []).map((member) => {
|
||||||
const isSelf = member.userId === user?.id;
|
const isSelf = member.userId === user?.id;
|
||||||
const canRemove =
|
const canKick = !isSelf && isFamilyOwner && member.role !== 'owner' && !member.isBot;
|
||||||
member.role !== 'owner' && (isFamilyOwner || isSelf);
|
|
||||||
const presence = presenceByUserId.get(member.userId);
|
const presence = presenceByUserId.get(member.userId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2272,7 +2349,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
🔒 E2E
|
🔒 E2E
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{canRemove ? (
|
{canKick ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -2282,7 +2359,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
void handleRemoveFromFamily(member.id);
|
void handleRemoveFromFamily(member.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isSelf ? 'Выйти' : 'Удалить'}
|
Удалить
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -2296,6 +2373,17 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
<UserPlus className="mr-2 h-4 w-4" />
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
Пригласить в семью
|
Пригласить в семью
|
||||||
</Button>
|
</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 ? (
|
{isFamilyOwner ? (
|
||||||
<Button
|
<Button
|
||||||
className="mt-2 w-full rounded-xl bg-red-600 hover:bg-red-700"
|
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>
|
</DialogContent>
|
||||||
</Dialog>
|
</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}>
|
<Dialog open={deleteFamilyDialogOpen} onOpenChange={setDeleteFamilyDialogOpen}>
|
||||||
<DialogContent className="rounded-[28px] sm:max-w-[440px]">
|
<DialogContent className="rounded-[28px] sm:max-w-[440px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -2366,7 +2535,14 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
className="h-10 w-10"
|
className="h-10 w-10"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<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="text-xs text-[#667085]">{memberRoleLabel(member)}</p>
|
||||||
<p className={cn('text-xs', presence?.online ? 'font-medium text-emerald-600' : 'text-[#a8adbc]')}>
|
<p className={cn('text-xs', presence?.online ? 'font-medium text-emerald-600' : 'text-[#a8adbc]')}>
|
||||||
{presence ? formatPresenceStatus(presence) : 'Статус неизвестен'}
|
{presence ? formatPresenceStatus(presence) : 'Статус неизвестен'}
|
||||||
|
|||||||
@@ -859,6 +859,7 @@ export interface ChatRoomMember {
|
|||||||
pinnedAt?: string;
|
pinnedAt?: string;
|
||||||
familyRole?: string;
|
familyRole?: string;
|
||||||
isChatCreator?: boolean;
|
isChatCreator?: boolean;
|
||||||
|
inFamily?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatRoom {
|
export interface ChatRoom {
|
||||||
@@ -874,6 +875,7 @@ export interface ChatRoom {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
pinnedAt?: string;
|
pinnedAt?: string;
|
||||||
|
peerLeftFamily?: boolean;
|
||||||
members: ChatRoomMember[];
|
members: ChatRoomMember[];
|
||||||
lastMessage?: ChatMessage;
|
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);
|
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) {
|
export async function fetchChatMessages(roomId: string, token?: string | null, beforeMessageId?: string) {
|
||||||
const query = beforeMessageId ? `?beforeMessageId=${beforeMessageId}` : '';
|
const query = beforeMessageId ? `?beforeMessageId=${beforeMessageId}` : '';
|
||||||
return apiFetch<{ messages?: ChatMessage[] }>(`/chat/rooms/${roomId}/messages${query}`, {}, token);
|
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);
|
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')
|
@GrpcMethod('FamilyService', 'SendFamilyInvite')
|
||||||
async sendFamilyInvite(command: { requesterId: string; groupId: string; target?: string; inviteeUserId?: string }) {
|
async sendFamilyInvite(command: { requesterId: string; groupId: string; target?: string; inviteeUserId?: string }) {
|
||||||
const invite = await this.family.sendInvite(command.requesterId, command.groupId, {
|
const invite = await this.family.sendInvite(command.requesterId, command.groupId, {
|
||||||
|
|||||||
@@ -940,12 +940,16 @@ export class ChatService {
|
|||||||
viewerId?: string
|
viewerId?: string
|
||||||
) {
|
) {
|
||||||
const viewerMembership = viewerId ? room.members.find((member) => member.userId === viewerId) : undefined;
|
const viewerMembership = viewerId ? room.members.find((member) => member.userId === viewerId) : undefined;
|
||||||
|
const activeFamilyUserIds = new Set(familyRoleByUserId.keys());
|
||||||
const peerMember =
|
const peerMember =
|
||||||
room.type === 'BOT'
|
room.type === 'BOT'
|
||||||
? room.members.find((member) => Boolean(member.user.linkedBotId))
|
? room.members.find((member) => Boolean(member.user.linkedBotId))
|
||||||
: viewerId && (room.type === 'DIRECT' || room.type === 'E2E')
|
: viewerId && (room.type === 'DIRECT' || room.type === 'E2E')
|
||||||
? room.members.find((member) => member.userId !== viewerId)
|
? room.members.find((member) => member.userId !== viewerId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const peerLeftFamily = Boolean(
|
||||||
|
peerMember && !activeFamilyUserIds.has(peerMember.userId) && !peerMember.user.linkedBotId
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: room.id,
|
id: room.id,
|
||||||
@@ -960,6 +964,7 @@ export class ChatService {
|
|||||||
updatedAt: room.updatedAt.toISOString(),
|
updatedAt: room.updatedAt.toISOString(),
|
||||||
isPinned: Boolean(viewerMembership?.pinnedAt),
|
isPinned: Boolean(viewerMembership?.pinnedAt),
|
||||||
pinnedAt: viewerMembership?.pinnedAt?.toISOString(),
|
pinnedAt: viewerMembership?.pinnedAt?.toISOString(),
|
||||||
|
peerLeftFamily,
|
||||||
members: room.members.map((member) => ({
|
members: room.members.map((member) => ({
|
||||||
id: member.id,
|
id: member.id,
|
||||||
userId: member.userId,
|
userId: member.userId,
|
||||||
@@ -970,7 +975,10 @@ export class ChatService {
|
|||||||
notificationsMuted: member.notificationsMuted,
|
notificationsMuted: member.notificationsMuted,
|
||||||
isPinned: Boolean(member.pinnedAt),
|
isPinned: Boolean(member.pinnedAt),
|
||||||
pinnedAt: member.pinnedAt?.toISOString(),
|
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
|
isChatCreator: room.createdById === member.userId
|
||||||
})),
|
})),
|
||||||
lastMessage
|
lastMessage
|
||||||
|
|||||||
@@ -212,12 +212,76 @@ export class FamilyService {
|
|||||||
|
|
||||||
for (const member of memberships) {
|
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: {
|
private async destroyGroupWithCleanup(group: {
|
||||||
|
|
||||||
id: string;
|
id: string;
|
||||||
@@ -399,7 +463,7 @@ export class FamilyService {
|
|||||||
|
|
||||||
where: { id: memberId },
|
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;
|
const isOwner = member.group.ownerId === requesterId;
|
||||||
|
|
||||||
if (!isSelf && !isOwner) {
|
if (isSelf) {
|
||||||
|
|
||||||
|
throw new BadRequestException('Для выхода из семьи используйте соответствующую функцию');
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOwner) {
|
||||||
|
|
||||||
throw new ForbiddenException('Недостаточно прав для удаления участника');
|
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 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);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ message ChatRoomMemberResponse {
|
|||||||
bool isChatCreator = 7;
|
bool isChatCreator = 7;
|
||||||
bool isPinned = 8;
|
bool isPinned = 8;
|
||||||
optional string pinnedAt = 9;
|
optional string pinnedAt = 9;
|
||||||
|
bool inFamily = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ChatRoomResponse {
|
message ChatRoomResponse {
|
||||||
@@ -176,6 +177,7 @@ message ChatRoomResponse {
|
|||||||
optional bool isE2E = 12;
|
optional bool isE2E = 12;
|
||||||
bool isPinned = 13;
|
bool isPinned = 13;
|
||||||
optional string pinnedAt = 14;
|
optional string pinnedAt = 14;
|
||||||
|
bool peerLeftFamily = 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
message PollOptionResponse {
|
message PollOptionResponse {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ service FamilyService {
|
|||||||
rpc DeleteFamilyGroup (DeleteFamilyGroupRequest) returns (MutationResponse);
|
rpc DeleteFamilyGroup (DeleteFamilyGroupRequest) returns (MutationResponse);
|
||||||
rpc AddFamilyMember (FamilyMemberRequest) returns (FamilyMemberResponse);
|
rpc AddFamilyMember (FamilyMemberRequest) returns (FamilyMemberResponse);
|
||||||
rpc RemoveFamilyMember (RemoveFamilyMemberRequest) returns (MutationResponse);
|
rpc RemoveFamilyMember (RemoveFamilyMemberRequest) returns (MutationResponse);
|
||||||
|
rpc LeaveFamilyGroup (LeaveFamilyGroupRequest) returns (LeaveFamilyGroupResponse);
|
||||||
rpc SendFamilyInvite (SendFamilyInviteRequest) returns (FamilyInviteResponse);
|
rpc SendFamilyInvite (SendFamilyInviteRequest) returns (FamilyInviteResponse);
|
||||||
rpc SearchFamilyInviteUsers (SearchFamilyInviteUsersRequest) returns (SearchFamilyInviteUsersResponse);
|
rpc SearchFamilyInviteUsers (SearchFamilyInviteUsersRequest) returns (SearchFamilyInviteUsersResponse);
|
||||||
rpc RespondFamilyInvite (RespondFamilyInviteRequest) returns (FamilyInviteResponse);
|
rpc RespondFamilyInvite (RespondFamilyInviteRequest) returns (FamilyInviteResponse);
|
||||||
@@ -241,6 +242,17 @@ message RemoveFamilyMemberRequest {
|
|||||||
string memberId = 2;
|
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 {
|
message SendFamilyInviteRequest {
|
||||||
string requesterId = 1;
|
string requesterId = 1;
|
||||||
string groupId = 2;
|
string groupId = 2;
|
||||||
|
|||||||
Reference in New Issue
Block a user