935 lines
18 KiB
TypeScript
935 lines
18 KiB
TypeScript
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||
|
||
import { PrismaService } from '../infra/prisma.service';
|
||
import { resolveVerificationIcon } from './verification.constants';
|
||
|
||
import { SettingsService } from './settings.service';
|
||
|
||
import { NotificationsService } from './notifications.service';
|
||
import { MinioService } from '../infra/minio.service';
|
||
|
||
|
||
|
||
const INVITE_TTL_DAYS = 7;
|
||
|
||
|
||
|
||
@Injectable()
|
||
|
||
export class FamilyService {
|
||
|
||
constructor(
|
||
|
||
private readonly prisma: PrismaService,
|
||
|
||
private readonly settings: SettingsService,
|
||
|
||
private readonly notifications: NotificationsService,
|
||
|
||
private readonly minio: MinioService
|
||
|
||
) {}
|
||
|
||
|
||
|
||
async create(ownerId: string, name: string) {
|
||
|
||
const trimmedName = name.trim();
|
||
|
||
if (!trimmedName) {
|
||
|
||
throw new BadRequestException('Укажите название семьи');
|
||
|
||
}
|
||
|
||
|
||
|
||
const group = await this.prisma.$transaction(async (tx) => {
|
||
|
||
const created = await tx.familyGroup.create({
|
||
|
||
data: {
|
||
|
||
ownerId,
|
||
|
||
name: trimmedName,
|
||
|
||
members: { create: { userId: ownerId, role: 'owner' } }
|
||
|
||
},
|
||
|
||
include: { members: { include: { user: true } } }
|
||
|
||
});
|
||
|
||
|
||
|
||
const generalRoom = await tx.chatRoom.create({
|
||
|
||
data: {
|
||
|
||
groupId: created.id,
|
||
|
||
type: 'GENERAL',
|
||
|
||
name: 'Общий чат',
|
||
|
||
createdById: ownerId,
|
||
|
||
members: { create: { userId: ownerId } }
|
||
|
||
}
|
||
|
||
});
|
||
|
||
|
||
|
||
return { ...created, generalRoomId: generalRoom.id };
|
||
|
||
});
|
||
|
||
|
||
|
||
return this.toGroup(group);
|
||
|
||
}
|
||
|
||
|
||
|
||
async list(userId: string) {
|
||
|
||
const groups = await this.prisma.familyGroup.findMany({
|
||
|
||
where: { OR: [{ ownerId: userId }, { members: { some: { userId } } }] },
|
||
|
||
include: { members: { include: { user: true } } },
|
||
|
||
orderBy: { updatedAt: 'desc' }
|
||
|
||
});
|
||
|
||
return { groups: groups.map((group) => this.toGroup(group)) };
|
||
|
||
}
|
||
|
||
|
||
|
||
async get(requesterId: string, groupId: string) {
|
||
|
||
const group = await this.getGroupWithMembers(groupId);
|
||
|
||
this.assertMember(group, requesterId);
|
||
|
||
return this.toGroup(group);
|
||
|
||
}
|
||
|
||
|
||
|
||
async update(requesterId: string, groupId: string, name: string) {
|
||
|
||
const group = await this.getGroupWithMembers(groupId);
|
||
|
||
this.assertOwner(group, requesterId);
|
||
|
||
const trimmedName = name.trim();
|
||
|
||
if (!trimmedName) {
|
||
|
||
throw new BadRequestException('Укажите название семьи');
|
||
|
||
}
|
||
|
||
const updated = await this.prisma.familyGroup.update({
|
||
|
||
where: { id: groupId },
|
||
|
||
data: { name: trimmedName },
|
||
|
||
include: { members: { include: { user: true } } }
|
||
|
||
});
|
||
|
||
return this.toGroup(updated);
|
||
|
||
}
|
||
|
||
|
||
|
||
async delete(requesterId: string, groupId: string) {
|
||
|
||
const group = await this.getGroupWithMembers(groupId);
|
||
|
||
this.assertOwner(group, requesterId);
|
||
|
||
await this.destroyGroupWithCleanup(group);
|
||
|
||
return { count: 1 };
|
||
|
||
}
|
||
|
||
async destroyOwnedGroupsForUser(userId: string) {
|
||
|
||
const groups = await this.prisma.familyGroup.findMany({
|
||
|
||
where: { ownerId: userId },
|
||
|
||
include: { members: { include: { user: true } } }
|
||
|
||
});
|
||
|
||
for (const group of groups) {
|
||
|
||
await this.destroyGroupWithCleanup(group);
|
||
|
||
}
|
||
|
||
}
|
||
|
||
async leaveAllMembershipsForUser(userId: string) {
|
||
|
||
const memberships = await this.prisma.familyMember.findMany({
|
||
|
||
where: { userId, role: { not: 'owner' } }
|
||
|
||
});
|
||
|
||
for (const member of memberships) {
|
||
|
||
await this.removeMember(userId, member.id);
|
||
|
||
}
|
||
|
||
}
|
||
|
||
private async destroyGroupWithCleanup(group: {
|
||
|
||
id: string;
|
||
|
||
ownerId: string;
|
||
|
||
name: string;
|
||
|
||
avatarStorageKey: string | null;
|
||
|
||
members: Array<{ userId: string }>;
|
||
|
||
}) {
|
||
|
||
const rooms = await this.prisma.chatRoom.findMany({
|
||
|
||
where: { groupId: group.id },
|
||
|
||
include: { messages: { select: { storageKey: true } } }
|
||
|
||
});
|
||
|
||
const storageKeys: string[] = [];
|
||
|
||
if (group.avatarStorageKey) {
|
||
|
||
storageKeys.push(group.avatarStorageKey);
|
||
|
||
}
|
||
|
||
for (const room of rooms) {
|
||
|
||
if (room.avatarStorageKey) {
|
||
|
||
storageKeys.push(room.avatarStorageKey);
|
||
|
||
}
|
||
|
||
for (const message of room.messages) {
|
||
|
||
if (message.storageKey) {
|
||
|
||
storageKeys.push(message.storageKey);
|
||
|
||
}
|
||
|
||
}
|
||
|
||
}
|
||
|
||
await this.minio.deleteObjects(storageKeys);
|
||
|
||
await this.prisma.familyGroup.delete({ where: { id: group.id } });
|
||
|
||
for (const member of group.members) {
|
||
|
||
await this.notifications.publishRealtime(
|
||
|
||
member.userId,
|
||
|
||
'family_group_deleted',
|
||
|
||
'Семья удалена',
|
||
|
||
`Семья «${group.name}» была удалена`,
|
||
|
||
{ groupId: group.id, groupName: group.name }
|
||
|
||
);
|
||
|
||
if (member.userId === group.ownerId) {
|
||
|
||
continue;
|
||
|
||
}
|
||
|
||
await this.notifications.create(
|
||
|
||
member.userId,
|
||
|
||
'family_group_deleted',
|
||
|
||
'Семья удалена',
|
||
|
||
`Семья «${group.name}» была удалена создателем`,
|
||
|
||
{ groupId: group.id, groupName: group.name }
|
||
|
||
);
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
|
||
async addMember(groupId: string, userId: string, role: string) {
|
||
|
||
const group = await this.prisma.familyGroup.findUnique({
|
||
|
||
where: { id: groupId },
|
||
|
||
include: { members: true }
|
||
|
||
});
|
||
|
||
if (!group) {
|
||
|
||
throw new BadRequestException('Семейная группа не найдена');
|
||
|
||
}
|
||
|
||
const maxMembers = await this.settings.getNumber('MAX_FAMILY_MEMBERS', 6);
|
||
|
||
if (group.members.length >= maxMembers) {
|
||
|
||
throw new BadRequestException(`В группе может быть не более ${maxMembers} участников`);
|
||
|
||
}
|
||
|
||
|
||
|
||
const member = await this.prisma.$transaction(async (tx) => {
|
||
|
||
const created = await tx.familyMember.create({ data: { groupId, userId, role } });
|
||
|
||
const rooms = await tx.chatRoom.findMany({ where: { groupId } });
|
||
|
||
for (const room of rooms) {
|
||
|
||
if (room.type === 'GENERAL') {
|
||
|
||
await tx.chatRoomMember.upsert({
|
||
|
||
where: { roomId_userId: { roomId: room.id, userId } },
|
||
|
||
create: { roomId: room.id, userId },
|
||
|
||
update: {}
|
||
|
||
});
|
||
|
||
}
|
||
|
||
}
|
||
|
||
return created;
|
||
|
||
});
|
||
|
||
|
||
|
||
return this.toMember(member);
|
||
|
||
}
|
||
|
||
|
||
|
||
async removeMember(requesterId: string, memberId: string) {
|
||
|
||
const member = await this.prisma.familyMember.findUnique({
|
||
|
||
where: { id: memberId },
|
||
|
||
include: { group: { include: { members: true } } }
|
||
|
||
});
|
||
|
||
if (!member) {
|
||
|
||
throw new NotFoundException('Участник не найден');
|
||
|
||
}
|
||
|
||
const isSelf = member.userId === requesterId;
|
||
|
||
const isOwner = member.group.ownerId === requesterId;
|
||
|
||
if (!isSelf && !isOwner) {
|
||
|
||
throw new ForbiddenException('Недостаточно прав для удаления участника');
|
||
|
||
}
|
||
|
||
if (member.role === 'owner') {
|
||
|
||
throw new BadRequestException('Нельзя удалить владельца семьи');
|
||
|
||
}
|
||
|
||
|
||
|
||
await this.prisma.$transaction(async (tx) => {
|
||
|
||
await tx.chatRoomMember.deleteMany({ where: { userId: member.userId, room: { groupId: member.groupId } } });
|
||
|
||
await tx.familyMember.delete({ where: { id: memberId } });
|
||
|
||
});
|
||
|
||
return { count: 1 };
|
||
|
||
}
|
||
|
||
|
||
|
||
async sendInvite(requesterId: string, groupId: string, options: { target?: string; inviteeUserId?: string }) {
|
||
const group = await this.getGroupWithMembers(groupId);
|
||
this.assertMember(group, requesterId);
|
||
|
||
const invitee = options.inviteeUserId
|
||
? await this.prisma.user.findFirst({
|
||
where: { id: options.inviteeUserId, status: 'ACTIVE', deletedAt: null }
|
||
})
|
||
: options.target
|
||
? await this.findUserByContact(options.target)
|
||
: null;
|
||
|
||
if (!invitee) {
|
||
throw new BadRequestException('Пользователь не найден. Выберите его из результатов поиска.');
|
||
}
|
||
|
||
if (invitee.id === requesterId) {
|
||
throw new BadRequestException('Нельзя пригласить самого себя');
|
||
}
|
||
|
||
if (group.members.some((member) => member.userId === invitee.id)) {
|
||
throw new BadRequestException('Пользователь уже состоит в семье');
|
||
}
|
||
|
||
const existingPending = await this.prisma.familyInvite.findFirst({
|
||
where: { groupId, inviteeUserId: invitee.id, status: 'PENDING' }
|
||
});
|
||
if (existingPending) {
|
||
throw new BadRequestException('Приглашение уже отправлено');
|
||
}
|
||
|
||
const inviter = await this.prisma.user.findUnique({ where: { id: requesterId } });
|
||
const invite = await this.prisma.familyInvite.create({
|
||
data: {
|
||
groupId,
|
||
inviterId: requesterId,
|
||
inviteeUserId: invitee.id,
|
||
targetEmail: invitee.email ?? undefined,
|
||
targetPhone: invitee.phone ?? undefined,
|
||
expiresAt: new Date(Date.now() + INVITE_TTL_DAYS * 24 * 60 * 60 * 1000)
|
||
},
|
||
include: {
|
||
group: true,
|
||
inviter: true
|
||
}
|
||
});
|
||
|
||
await this.notifications.create(
|
||
invitee.id,
|
||
'family_invite',
|
||
'Приглашение в семью',
|
||
`${inviter?.displayName ?? 'Участник'} приглашает вас в «${group.name}»`,
|
||
{ inviteId: invite.id, groupId, groupName: group.name }
|
||
);
|
||
|
||
return this.toInvite(invite);
|
||
}
|
||
|
||
async searchInviteUsers(requesterId: string, groupId: string, query: string) {
|
||
const group = await this.getGroupWithMembers(groupId);
|
||
this.assertMember(group, requesterId);
|
||
|
||
const trimmed = query.trim();
|
||
if (trimmed.length < 2) {
|
||
return { users: [] };
|
||
}
|
||
|
||
const excludedIds = [...group.members.map((member) => member.userId), requesterId];
|
||
const digits = trimmed.replace(/\D/g, '');
|
||
const users = await this.prisma.user.findMany({
|
||
where: {
|
||
status: 'ACTIVE',
|
||
deletedAt: null,
|
||
id: { notIn: excludedIds },
|
||
OR: [
|
||
{ displayName: { contains: trimmed, mode: 'insensitive' } },
|
||
{ username: { contains: trimmed, mode: 'insensitive' } },
|
||
...(trimmed.includes('@') ? [{ email: { contains: trimmed, mode: 'insensitive' as const } }] : []),
|
||
...(digits.length >= 4 ? [{ phone: { contains: digits } }] : [])
|
||
]
|
||
},
|
||
orderBy: { displayName: 'asc' },
|
||
take: 8,
|
||
select: {
|
||
id: true,
|
||
displayName: true,
|
||
email: true,
|
||
phone: true,
|
||
username: true,
|
||
avatarStorageKey: true,
|
||
isVerified: true,
|
||
verificationIcon: true
|
||
}
|
||
});
|
||
|
||
return {
|
||
users: users.map((user) => ({
|
||
id: user.id,
|
||
displayName: user.displayName,
|
||
email: user.email,
|
||
phone: user.phone,
|
||
username: user.username,
|
||
hasAvatar: Boolean(user.avatarStorageKey),
|
||
isVerified: user.isVerified,
|
||
verificationIcon: user.isVerified ? resolveVerificationIcon(user.verificationIcon) : undefined
|
||
}))
|
||
};
|
||
}
|
||
|
||
|
||
|
||
async respondInvite(userId: string, inviteId: string, accept: boolean) {
|
||
|
||
const invite = await this.prisma.familyInvite.findUnique({
|
||
|
||
where: { id: inviteId },
|
||
|
||
include: { group: { include: { members: true } }, inviter: true }
|
||
|
||
});
|
||
|
||
if (!invite) {
|
||
|
||
throw new NotFoundException('Приглашение не найдено');
|
||
|
||
}
|
||
|
||
if (invite.status !== 'PENDING') {
|
||
|
||
throw new BadRequestException('Приглашение уже обработано');
|
||
|
||
}
|
||
|
||
if (invite.expiresAt.getTime() < Date.now()) {
|
||
|
||
await this.prisma.familyInvite.update({ where: { id: inviteId }, data: { status: 'EXPIRED' } });
|
||
|
||
throw new BadRequestException('Срок действия приглашения истёк');
|
||
|
||
}
|
||
|
||
if (invite.inviteeUserId && invite.inviteeUserId !== userId) {
|
||
|
||
throw new ForbiddenException('Это приглашение предназначено другому пользователю');
|
||
|
||
}
|
||
|
||
|
||
|
||
if (!accept) {
|
||
|
||
const declined = await this.prisma.familyInvite.update({
|
||
|
||
where: { id: inviteId },
|
||
|
||
data: { status: 'DECLINED', inviteeUserId: invite.inviteeUserId ?? userId },
|
||
|
||
include: { group: true, inviter: true }
|
||
|
||
});
|
||
|
||
await this.notifications.dismissFamilyInvite(userId, inviteId);
|
||
|
||
return this.toInvite(declined);
|
||
|
||
}
|
||
|
||
|
||
|
||
const maxMembers = await this.settings.getNumber('MAX_FAMILY_MEMBERS', 6);
|
||
|
||
if (invite.group.members.length >= maxMembers) {
|
||
|
||
throw new BadRequestException(`В семье уже максимум ${maxMembers} участников`);
|
||
|
||
}
|
||
|
||
|
||
|
||
await this.prisma.$transaction(async (tx) => {
|
||
|
||
await tx.familyInvite.update({
|
||
|
||
where: { id: inviteId },
|
||
|
||
data: { status: 'ACCEPTED', inviteeUserId: userId }
|
||
|
||
});
|
||
|
||
await tx.familyMember.create({
|
||
|
||
data: { groupId: invite.groupId, userId, role: 'member' }
|
||
|
||
});
|
||
|
||
const rooms = await tx.chatRoom.findMany({ where: { groupId: invite.groupId } });
|
||
|
||
for (const room of rooms) {
|
||
|
||
if (room.type === 'GENERAL') {
|
||
|
||
await tx.chatRoomMember.upsert({
|
||
|
||
where: { roomId_userId: { roomId: room.id, userId } },
|
||
|
||
create: { roomId: room.id, userId },
|
||
|
||
update: {}
|
||
|
||
});
|
||
|
||
}
|
||
|
||
}
|
||
|
||
});
|
||
|
||
|
||
|
||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||
|
||
await this.notifications.create(
|
||
|
||
invite.inviterId,
|
||
|
||
'family_invite_accepted',
|
||
|
||
'Приглашение принято',
|
||
|
||
`${user?.displayName ?? 'Пользователь'} присоединился к «${invite.group.name}»`,
|
||
|
||
{ groupId: invite.groupId, userId }
|
||
|
||
);
|
||
|
||
|
||
|
||
const updated = await this.prisma.familyInvite.findUnique({
|
||
|
||
where: { id: inviteId },
|
||
|
||
include: { group: true, inviter: true }
|
||
|
||
});
|
||
|
||
await this.notifications.dismissFamilyInvite(userId, inviteId);
|
||
|
||
return this.toInvite(updated!);
|
||
|
||
}
|
||
|
||
|
||
|
||
async listInvites(userId: string, groupId?: string) {
|
||
|
||
const invites = await this.prisma.familyInvite.findMany({
|
||
|
||
where: {
|
||
|
||
...(groupId ? { groupId } : {}),
|
||
inviteeUserId: userId,
|
||
status: 'PENDING'
|
||
},
|
||
|
||
include: { group: true, inviter: true },
|
||
|
||
orderBy: { createdAt: 'desc' }
|
||
|
||
});
|
||
|
||
return { invites: invites.map((invite) => this.toInvite(invite)) };
|
||
|
||
}
|
||
|
||
|
||
|
||
async assertFamilyMember(groupId: string, userId: string) {
|
||
|
||
const group = await this.getGroupWithMembers(groupId);
|
||
|
||
this.assertMember(group, userId);
|
||
|
||
return group;
|
||
|
||
}
|
||
|
||
|
||
|
||
private async getGroupWithMembers(groupId: string) {
|
||
|
||
const group = await this.prisma.familyGroup.findUnique({
|
||
|
||
where: { id: groupId },
|
||
|
||
include: { members: { include: { user: true } } }
|
||
|
||
});
|
||
|
||
if (!group) {
|
||
|
||
throw new NotFoundException('Семейная группа не найдена');
|
||
|
||
}
|
||
|
||
return group;
|
||
|
||
}
|
||
|
||
|
||
|
||
private assertMember(group: { members: Array<{ userId: string }> }, userId: string) {
|
||
|
||
if (!group.members.some((member) => member.userId === userId)) {
|
||
|
||
throw new ForbiddenException('Вы не состоите в этой семье');
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
|
||
private assertOwner(group: { ownerId: string }, userId: string) {
|
||
|
||
if (group.ownerId !== userId) {
|
||
|
||
throw new ForbiddenException('Только владелец семьи может выполнить это действие');
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
|
||
private async findUserByContact(target: string) {
|
||
const trimmed = target.trim();
|
||
if (!trimmed) {
|
||
return null;
|
||
}
|
||
|
||
if (trimmed.includes('@')) {
|
||
return this.prisma.user.findFirst({
|
||
where: { email: { equals: trimmed, mode: 'insensitive' }, status: 'ACTIVE', deletedAt: null }
|
||
});
|
||
}
|
||
|
||
const digits = trimmed.replace(/\D/g, '');
|
||
if (/^\+?\d/.test(trimmed) && digits.length >= 10) {
|
||
return this.prisma.user.findFirst({
|
||
where: {
|
||
status: 'ACTIVE',
|
||
deletedAt: null,
|
||
OR: [{ phone: trimmed }, { phone: { endsWith: digits.slice(-10) } }]
|
||
}
|
||
});
|
||
}
|
||
|
||
return this.prisma.user.findFirst({
|
||
where: {
|
||
status: 'ACTIVE',
|
||
deletedAt: null,
|
||
OR: [
|
||
{ username: { equals: trimmed, mode: 'insensitive' } },
|
||
{ displayName: { equals: trimmed, mode: 'insensitive' } }
|
||
]
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
|
||
private toGroup(group: {
|
||
|
||
id: string;
|
||
|
||
ownerId: string;
|
||
|
||
name: string;
|
||
|
||
hasAvatar: boolean;
|
||
|
||
createdAt: Date;
|
||
|
||
updatedAt: Date;
|
||
|
||
members: Array<{
|
||
|
||
id: string;
|
||
|
||
groupId: string;
|
||
|
||
userId: string;
|
||
|
||
role: string;
|
||
|
||
createdAt: Date;
|
||
|
||
user?: { displayName: string; avatarStorageKey: string | null; isVerified: boolean; verificationIcon: string | null };
|
||
|
||
}>;
|
||
|
||
}) {
|
||
|
||
return {
|
||
|
||
id: group.id,
|
||
|
||
ownerId: group.ownerId,
|
||
|
||
name: group.name,
|
||
|
||
hasAvatar: group.hasAvatar,
|
||
|
||
createdAt: group.createdAt.toISOString(),
|
||
|
||
updatedAt: group.updatedAt.toISOString(),
|
||
|
||
members: group.members.map((member) => this.toMember(member))
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
private toMember(member: {
|
||
|
||
id: string;
|
||
|
||
groupId: string;
|
||
|
||
userId: string;
|
||
|
||
role: string;
|
||
|
||
createdAt: Date;
|
||
|
||
user?: { displayName: string; avatarStorageKey: string | null; isVerified: boolean; verificationIcon: string | null };
|
||
|
||
}) {
|
||
|
||
return {
|
||
|
||
id: member.id,
|
||
|
||
groupId: member.groupId,
|
||
|
||
userId: member.userId,
|
||
|
||
role: member.role,
|
||
|
||
displayName: member.user?.displayName ?? 'Участник',
|
||
|
||
hasAvatar: Boolean(member.user?.avatarStorageKey),
|
||
|
||
isVerified: member.user?.isVerified ?? false,
|
||
|
||
verificationIcon: member.user?.isVerified ? resolveVerificationIcon(member.user?.verificationIcon) : undefined,
|
||
|
||
createdAt: member.createdAt.toISOString()
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
private toInvite(invite: {
|
||
|
||
id: string;
|
||
|
||
groupId: string;
|
||
|
||
inviterId: string;
|
||
|
||
inviteeUserId: string | null;
|
||
|
||
targetEmail: string | null;
|
||
|
||
targetPhone: string | null;
|
||
|
||
status: string;
|
||
|
||
expiresAt: Date;
|
||
|
||
createdAt: Date;
|
||
|
||
group: { name: string };
|
||
|
||
inviter: { displayName: string };
|
||
|
||
}) {
|
||
|
||
return {
|
||
|
||
id: invite.id,
|
||
|
||
groupId: invite.groupId,
|
||
|
||
groupName: invite.group.name,
|
||
|
||
inviterId: invite.inviterId,
|
||
|
||
inviterName: invite.inviter.displayName,
|
||
|
||
inviteeUserId: invite.inviteeUserId ?? undefined,
|
||
|
||
targetEmail: invite.targetEmail ?? undefined,
|
||
|
||
targetPhone: invite.targetPhone ?? undefined,
|
||
|
||
status: invite.status,
|
||
|
||
expiresAt: invite.expiresAt.toISOString(),
|
||
|
||
createdAt: invite.createdAt.toISOString()
|
||
|
||
};
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|