fix sso core folder

This commit is contained in:
lendry
2026-06-24 14:45:45 +03:00
parent 995adeedd4
commit d2bbf35d40
45 changed files with 6592 additions and 1 deletions

View 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()
};
}
}