fix sso core folder
This commit is contained in:
786
apps/sso-core/src/domain/family.service.ts
Normal file
786
apps/sso-core/src/domain/family.service.ts
Normal file
@@ -0,0 +1,786 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../infra/prisma.service';
|
||||
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
|
||||
|
||||
const INVITE_TTL_DAYS = 7;
|
||||
|
||||
|
||||
|
||||
@Injectable()
|
||||
|
||||
export class FamilyService {
|
||||
|
||||
constructor(
|
||||
|
||||
private readonly prisma: PrismaService,
|
||||
|
||||
private readonly settings: SettingsService,
|
||||
|
||||
private readonly notifications: NotificationsService
|
||||
|
||||
) {}
|
||||
|
||||
|
||||
|
||||
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.prisma.familyGroup.delete({ where: { id: groupId } });
|
||||
|
||||
return { count: 1 };
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
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, target: string) {
|
||||
|
||||
const group = await this.getGroupWithMembers(groupId);
|
||||
|
||||
this.assertMember(group, requesterId);
|
||||
|
||||
|
||||
|
||||
const trimmedTarget = target.trim();
|
||||
|
||||
if (!trimmedTarget) {
|
||||
|
||||
throw new BadRequestException('Укажите email, телефон или логин приглашаемого');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const invitee = await this.findUserByContact(trimmedTarget);
|
||||
|
||||
if (invitee?.id === requesterId) {
|
||||
|
||||
throw new BadRequestException('Нельзя пригласить самого себя');
|
||||
|
||||
}
|
||||
|
||||
if (invitee && group.members.some((member) => member.userId === invitee.id)) {
|
||||
|
||||
throw new BadRequestException('Пользователь уже состоит в семье');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const existingPending = invitee
|
||||
|
||||
? await this.prisma.familyInvite.findFirst({
|
||||
|
||||
where: { groupId, inviteeUserId: invitee.id, status: 'PENDING' }
|
||||
|
||||
})
|
||||
|
||||
: null;
|
||||
|
||||
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: trimmedTarget.includes('@') ? trimmedTarget : undefined,
|
||||
|
||||
targetPhone: !trimmedTarget.includes('@') && /^\+?\d/.test(trimmedTarget) ? trimmedTarget : undefined,
|
||||
|
||||
expiresAt: new Date(Date.now() + INVITE_TTL_DAYS * 24 * 60 * 60 * 1000)
|
||||
|
||||
},
|
||||
|
||||
include: {
|
||||
|
||||
group: true,
|
||||
|
||||
inviter: true
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
if (invitee) {
|
||||
|
||||
await this.notifications.create(
|
||||
|
||||
invitee.id,
|
||||
|
||||
'family_invite',
|
||||
|
||||
'Приглашение в семью',
|
||||
|
||||
`${inviter?.displayName ?? 'Участник'} приглашает вас в «${group.name}»`,
|
||||
|
||||
{ inviteId: invite.id, groupId, groupName: group.name }
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return this.toInvite(invite);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
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) {
|
||||
|
||||
if (target.includes('@')) {
|
||||
|
||||
return this.prisma.user.findFirst({ where: { email: target, status: 'ACTIVE' } });
|
||||
|
||||
}
|
||||
|
||||
const normalizedPhone = target.replace(/\D/g, '');
|
||||
|
||||
return this.prisma.user.findFirst({
|
||||
|
||||
where: {
|
||||
|
||||
status: 'ACTIVE',
|
||||
|
||||
OR: [{ phone: target }, { phone: { contains: normalizedPhone.slice(-10) } }, { username: target }]
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
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 };
|
||||
|
||||
}>;
|
||||
|
||||
}) {
|
||||
|
||||
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 };
|
||||
|
||||
}) {
|
||||
|
||||
return {
|
||||
|
||||
id: member.id,
|
||||
|
||||
groupId: member.groupId,
|
||||
|
||||
userId: member.userId,
|
||||
|
||||
role: member.role,
|
||||
|
||||
displayName: member.user?.displayName ?? 'Участник',
|
||||
|
||||
hasAvatar: Boolean(member.user?.avatarStorageKey),
|
||||
|
||||
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()
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user