family update

This commit is contained in:
lendry
2026-06-24 23:17:24 +03:00
parent 9727cf3f35
commit f2366a69a0
18 changed files with 1374 additions and 103 deletions

View File

@@ -10,6 +10,11 @@ interface PollPayload {
isAnonymous?: boolean;
}
interface ReadContextMember {
userId: string;
lastReadAt: Date | null;
}
@Injectable()
export class ChatService {
constructor(
@@ -51,27 +56,18 @@ export class ChatService {
orderBy: [{ type: 'asc' }, { updatedAt: 'desc' }]
});
const familyMembers = await this.prisma.familyMember.findMany({ where: { groupId } });
const familyRoleByUserId = new Map(familyMembers.map((member) => [member.userId, member.role]));
return {
rooms: await Promise.all(
rooms.map(async (room) => {
const membership = room.members.find((member) => member.userId === userId);
const lastMessage = room.messages[0];
return {
id: room.id,
groupId: room.groupId,
type: room.type,
name: room.name,
hasAvatar: room.hasAvatar,
updatedAt: room.updatedAt.toISOString(),
members: room.members.map((member) => ({
id: member.id,
userId: member.userId,
displayName: member.user.displayName,
hasAvatar: Boolean(member.user.avatarStorageKey),
notificationsMuted: member.notificationsMuted
})),
lastMessage: lastMessage ? await this.toMessage(lastMessage, userId) : undefined
};
return this.formatRoom(
room,
familyRoleByUserId,
lastMessage ? await this.toMessage(lastMessage, userId, await this.getRoomReadContext(room.id)) : undefined
);
})
)
};
@@ -107,21 +103,61 @@ export class ChatService {
}
});
return {
id: room.id,
groupId: room.groupId,
type: room.type,
name: room.name,
hasAvatar: room.hasAvatar,
updatedAt: room.updatedAt.toISOString(),
members: room.members.map((member) => ({
id: member.id,
userId: member.userId,
displayName: member.user.displayName,
hasAvatar: Boolean(member.user.avatarStorageKey),
notificationsMuted: member.notificationsMuted
}))
};
const familyMembers = await this.prisma.familyMember.findMany({ where: { groupId } });
const familyRoleByUserId = new Map(familyMembers.map((member) => [member.userId, member.role]));
return this.formatRoom(room, familyRoleByUserId);
}
async addRoomMember(userId: string, roomId: string, memberUserId: string) {
const room = await this.getRoomForMember(roomId, userId);
if (room.type === 'GENERAL') {
throw new BadRequestException('В общий чат нельзя добавлять участников вручную');
}
if (room.members.some((member) => member.userId === memberUserId)) {
throw new BadRequestException('Пользователь уже состоит в чате');
}
if (!room.group.members.some((member) => member.userId === memberUserId)) {
throw new BadRequestException('Пользователь должен состоять в семье');
}
await this.prisma.chatRoomMember.create({
data: { roomId, userId: memberUserId }
});
return this.getRoomResponse(roomId, userId);
}
async removeRoomMember(userId: string, roomId: string, memberUserId: string) {
const room = await this.getRoomForMember(roomId, userId);
if (room.type === 'GENERAL') {
throw new BadRequestException('Нельзя удалить участника из общего чата');
}
const isFamilyOwner = room.group.ownerId === userId;
const isChatCreator = room.createdById === userId;
if (!isFamilyOwner && !isChatCreator) {
throw new ForbiddenException('Недостаточно прав для удаления участника из чата');
}
if (memberUserId === room.createdById && !isFamilyOwner) {
throw new BadRequestException('Создателя чата может удалить только создатель семьи');
}
if (memberUserId === userId && room.members.length <= 1) {
throw new BadRequestException('В чате должен остаться хотя бы один участник');
}
const membership = room.members.find((member) => member.userId === memberUserId);
if (!membership) {
throw new NotFoundException('Участник не найден в этом чате');
}
await this.prisma.chatRoomMember.delete({
where: { roomId_userId: { roomId, userId: memberUserId } }
});
return this.getRoomResponse(roomId, userId);
}
async updateRoomSettings(userId: string, roomId: string, name?: string, notificationsMuted?: boolean) {
@@ -148,6 +184,7 @@ export class ChatService {
async listMessages(userId: string, roomId: string, beforeMessageId?: string, limit = 50) {
await this.getRoomForMember(roomId, userId);
const take = Math.min(Math.max(limit, 1), 100);
const readContext = await this.getRoomReadContext(roomId);
let cursorDate: Date | undefined;
if (beforeMessageId) {
@@ -170,7 +207,7 @@ export class ChatService {
const ordered = messages.reverse();
return {
messages: await Promise.all(ordered.map((message) => this.toMessage(message, userId)))
messages: await Promise.all(ordered.map((message) => this.toMessage(message, userId, readContext)))
};
}
@@ -255,11 +292,93 @@ export class ChatService {
}
});
const response = await this.toMessage(fullMessage!, userId);
const readContext = await this.getRoomReadContext(roomId);
const response = await this.toMessage(fullMessage!, userId, readContext);
await this.notifyRoomMembers(room, userId, response);
return response;
}
async editMessage(userId: string, messageId: string, content: string) {
const message = await this.prisma.chatMessage.findUnique({
where: { id: messageId },
include: { room: { include: { members: true } } }
});
if (!message || message.deletedAt) {
throw new NotFoundException('Сообщение не найдено');
}
if (message.senderId !== userId) {
throw new ForbiddenException('Можно редактировать только свои сообщения');
}
if (!['TEXT', 'EMOJI'].includes(message.type)) {
throw new BadRequestException('Этот тип сообщения нельзя редактировать');
}
const trimmed = content.trim();
if (!trimmed) {
throw new BadRequestException('Сообщение не может быть пустым');
}
await this.prisma.chatMessage.update({
where: { id: messageId },
data: { content: trimmed, editedAt: new Date() }
});
const response = await this.loadMessageResponse(messageId, userId);
await this.publishRoomEvent(message.room, 'chat_message_updated', response);
return response;
}
async deleteMessage(userId: string, messageId: string) {
const message = await this.prisma.chatMessage.findUnique({
where: { id: messageId },
include: { room: { include: { members: true } } }
});
if (!message || message.deletedAt) {
throw new NotFoundException('Сообщение не найдено');
}
if (message.senderId !== userId) {
throw new ForbiddenException('Можно удалять только свои сообщения');
}
await this.prisma.chatMessage.update({
where: { id: messageId },
data: { deletedAt: new Date(), content: null, storageKey: null, mimeType: null, metadata: undefined }
});
const response = await this.loadMessageResponse(messageId, userId);
await this.publishRoomEvent(message.room, 'chat_message_deleted', response);
return response;
}
async markRoomRead(userId: string, roomId: string, lastMessageId?: string) {
const room = await this.getRoomForMember(roomId, userId);
let readAt = new Date();
if (lastMessageId) {
const message = await this.prisma.chatMessage.findFirst({ where: { id: lastMessageId, roomId } });
if (message) {
readAt = message.createdAt;
}
}
await this.prisma.chatRoomMember.update({
where: { roomId_userId: { roomId, userId } },
data: { lastReadAt: readAt, lastReadMessageId: lastMessageId ?? null }
});
for (const member of room.members) {
if (member.userId === userId) continue;
await this.notifications.publishRealtime(member.userId, 'chat_read_receipt', room.name, '', {
roomId,
userId,
lastReadAt: readAt.toISOString(),
lastReadMessageId: lastMessageId
});
}
return { count: 1 };
}
async votePoll(userId: string, messageId: string, optionIds: string[]) {
const message = await this.prisma.chatMessage.findUnique({
where: { id: messageId },
@@ -302,7 +421,7 @@ export class ChatService {
poll: { include: { options: { include: { votes: true } } } }
}
});
return this.toMessage(updated!, userId);
return this.toMessage(updated!, userId, await this.getRoomReadContext(updated!.roomId));
}
async setRoomNotificationsMuted(userId: string, roomId: string, muted: boolean) {
@@ -335,27 +454,88 @@ export class ChatService {
return room;
}
private formatRoom(
room: {
id: string;
groupId: string;
type: string;
name: string;
hasAvatar: boolean;
updatedAt: Date;
createdById: string | null;
members: Array<{
id: string;
userId: string;
notificationsMuted: boolean;
user: { displayName: string; avatarStorageKey: string | null };
}>;
},
familyRoleByUserId: Map<string, string>,
lastMessage?: Awaited<ReturnType<ChatService['toMessage']>>
) {
return {
id: room.id,
groupId: room.groupId,
type: room.type,
name: room.name,
hasAvatar: room.hasAvatar,
createdById: room.createdById ?? undefined,
updatedAt: room.updatedAt.toISOString(),
members: room.members.map((member) => ({
id: member.id,
userId: member.userId,
displayName: member.user.displayName,
hasAvatar: Boolean(member.user.avatarStorageKey),
notificationsMuted: member.notificationsMuted,
familyRole: familyRoleByUserId.get(member.userId) ?? 'member',
isChatCreator: room.createdById === member.userId
})),
lastMessage
};
}
private async getRoomResponse(roomId: string, userId: string) {
const room = await this.getRoomForMember(roomId, userId);
const full = await this.prisma.chatRoom.findUnique({
where: { id: roomId },
include: { members: { include: { user: true } } }
});
return {
id: full!.id,
groupId: full!.groupId,
type: full!.type,
name: full!.name,
hasAvatar: full!.hasAvatar,
updatedAt: full!.updatedAt.toISOString(),
members: full!.members.map((member) => ({
id: member.id,
userId: member.userId,
displayName: member.user.displayName,
hasAvatar: Boolean(member.user.avatarStorageKey),
notificationsMuted: member.notificationsMuted
}))
};
const familyMembers = await this.prisma.familyMember.findMany({ where: { groupId: full!.groupId } });
const familyRoleByUserId = new Map(familyMembers.map((member) => [member.userId, member.role]));
return this.formatRoom(full!, familyRoleByUserId);
}
private async getRoomReadContext(roomId: string): Promise<ReadContextMember[]> {
const members = await this.prisma.chatRoomMember.findMany({ where: { roomId } });
return members.map((member) => ({ userId: member.userId, lastReadAt: member.lastReadAt }));
}
private async loadMessageResponse(messageId: string, viewerId: string) {
const message = await this.prisma.chatMessage.findUnique({
where: { id: messageId },
include: {
sender: true,
poll: { include: { options: { include: { votes: true } } } }
}
});
if (!message) {
throw new NotFoundException('Сообщение не найдено');
}
const readContext = await this.getRoomReadContext(message.roomId);
return this.toMessage(message, viewerId, readContext);
}
private async publishRoomEvent(
room: { id: string; name: string; members: Array<{ userId: string }> },
eventType: string,
message: Awaited<ReturnType<ChatService['toMessage']>>
) {
for (const member of room.members) {
await this.notifications.publishRealtime(member.userId, eventType, room.name, '', {
roomId: room.id,
message
});
}
}
private async notifyRoomMembers(
@@ -412,6 +592,8 @@ export class ChatService {
mimeType: string | null;
metadata: unknown;
createdAt: Date;
editedAt?: Date | null;
deletedAt?: Date | null;
sender: { displayName: string; avatarStorageKey: string | null };
poll?: {
id: string;
@@ -422,8 +604,17 @@ export class ChatService {
options: Array<{ id: string; text: string; votes: Array<{ userId: string }> }>;
} | null;
},
viewerId: string
viewerId: string,
readContext?: ReadContextMember[]
) {
const isDeleted = Boolean(message.deletedAt);
let readByAll = false;
if (readContext && message.senderId === viewerId && !isDeleted) {
const others = readContext.filter((member) => member.userId !== message.senderId);
readByAll =
others.length > 0 && others.every((member) => member.lastReadAt && member.lastReadAt >= message.createdAt);
}
return {
id: message.id,
roomId: message.roomId,
@@ -431,13 +622,16 @@ export class ChatService {
senderName: message.sender.displayName,
senderHasAvatar: Boolean(message.sender.avatarStorageKey),
type: message.type,
content: message.content ?? undefined,
content: isDeleted ? undefined : message.content ?? undefined,
replyToId: message.replyToId ?? undefined,
storageKey: message.storageKey ?? undefined,
mimeType: message.mimeType ?? undefined,
metadataJson: message.metadata ? JSON.stringify(message.metadata) : undefined,
storageKey: isDeleted ? undefined : message.storageKey ?? undefined,
mimeType: isDeleted ? undefined : message.mimeType ?? undefined,
metadataJson: isDeleted ? undefined : message.metadata ? JSON.stringify(message.metadata) : undefined,
createdAt: message.createdAt.toISOString(),
poll: message.poll
editedAt: message.editedAt?.toISOString(),
isDeleted,
readByAll: message.senderId === viewerId ? readByAll : undefined,
poll: !isDeleted && message.poll
? {
id: message.poll.id,
question: message.poll.question,