family update
This commit is contained in:
@@ -384,8 +384,10 @@ model ChatRoomMember {
|
||||
id String @id @default(uuid())
|
||||
roomId String
|
||||
userId String
|
||||
notificationsMuted Boolean @default(false)
|
||||
joinedAt DateTime @default(now())
|
||||
notificationsMuted Boolean @default(false)
|
||||
lastReadAt DateTime?
|
||||
lastReadMessageId String?
|
||||
joinedAt DateTime @default(now())
|
||||
room ChatRoom @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -404,6 +406,7 @@ model ChatMessage {
|
||||
mimeType String?
|
||||
createdAt DateTime @default(now())
|
||||
editedAt DateTime?
|
||||
deletedAt DateTime?
|
||||
room ChatRoom @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
|
||||
poll ChatPoll?
|
||||
|
||||
@@ -26,6 +26,7 @@ import { FamilyService } from './domain/family.service';
|
||||
import { MediaService } from './domain/media.service';
|
||||
import { NotificationsService } from './domain/notifications.service';
|
||||
import { ChatService } from './domain/chat.service';
|
||||
import { PresenceService } from './domain/presence.service';
|
||||
import { NotificationPublisherService } from './infra/notification-publisher.service';
|
||||
import { MinioService } from './infra/minio.service';
|
||||
import { LdapClientService } from './infra/ldap-client.service';
|
||||
@@ -63,6 +64,7 @@ import { TotpService } from './domain/totp.service';
|
||||
MediaService,
|
||||
NotificationsService,
|
||||
ChatService,
|
||||
PresenceService,
|
||||
NotificationPublisherService,
|
||||
MinioService,
|
||||
LdapClientService,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { FamilyService } from './family.service';
|
||||
import { MediaService } from './media.service';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { ChatService } from './chat.service';
|
||||
import { PresenceService } from './presence.service';
|
||||
import { TotpService } from './totp.service';
|
||||
import { MessagingService } from '../infra/messaging.service';
|
||||
|
||||
@@ -44,6 +45,7 @@ export class AuthGrpcController {
|
||||
private readonly media: MediaService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly chat: ChatService,
|
||||
private readonly presence: PresenceService,
|
||||
private readonly messaging: MessagingService,
|
||||
private readonly totp: TotpService
|
||||
) {}
|
||||
@@ -624,6 +626,21 @@ export class AuthGrpcController {
|
||||
return this.media.getChatMediaAccessUrl(command.requesterId, command.roomId, command.storageKey, command.fileName);
|
||||
}
|
||||
|
||||
@GrpcMethod('MediaService', 'CreateChatRoomAvatarUploadUrl')
|
||||
createChatRoomAvatarUploadUrl(command: { requesterId: string; roomId: string; contentType: string }) {
|
||||
return this.media.createChatRoomAvatarUploadUrl(command.requesterId, command.roomId, command.contentType);
|
||||
}
|
||||
|
||||
@GrpcMethod('MediaService', 'ConfirmChatRoomAvatar')
|
||||
confirmChatRoomAvatar(command: { requesterId: string; roomId: string; storageKey: string }) {
|
||||
return this.media.confirmChatRoomAvatar(command.requesterId, command.roomId, command.storageKey);
|
||||
}
|
||||
|
||||
@GrpcMethod('MediaService', 'GetChatRoomAvatarAccessUrl')
|
||||
getChatRoomAvatarAccessUrl(command: { requesterId: string; roomId: string }) {
|
||||
return this.media.getChatRoomAvatarAccessUrl(command.requesterId, command.roomId);
|
||||
}
|
||||
|
||||
@GrpcMethod('OAuthCoreService', 'Authorize')
|
||||
authorize(command: { userId: string; clientId: string; redirectUri: string; scope: string; state?: string }) {
|
||||
return this.oauthCore.authorize(command);
|
||||
@@ -738,6 +755,11 @@ export class AuthGrpcController {
|
||||
return this.family.listInvites(command.userId, command.groupId);
|
||||
}
|
||||
|
||||
@GrpcMethod('FamilyService', 'GetFamilyPresence')
|
||||
getFamilyPresence(command: { requesterId: string; groupId: string }) {
|
||||
return this.presence.getFamilyPresence(command.requesterId, command.groupId);
|
||||
}
|
||||
|
||||
@GrpcMethod('NotificationsService', 'ListNotifications')
|
||||
listNotifications(command: { userId: string; limit?: number; unreadOnly?: boolean }) {
|
||||
return this.notifications.list(command.userId, command.limit, command.unreadOnly);
|
||||
@@ -783,6 +805,16 @@ export class AuthGrpcController {
|
||||
return this.chat.updateRoomSettings(command.userId, command.roomId, command.name, command.notificationsMuted);
|
||||
}
|
||||
|
||||
@GrpcMethod('ChatService', 'AddRoomMember')
|
||||
addChatRoomMember(command: { userId: string; roomId: string; memberUserId: string }) {
|
||||
return this.chat.addRoomMember(command.userId, command.roomId, command.memberUserId);
|
||||
}
|
||||
|
||||
@GrpcMethod('ChatService', 'RemoveRoomMember')
|
||||
removeChatRoomMember(command: { userId: string; roomId: string; memberUserId: string }) {
|
||||
return this.chat.removeRoomMember(command.userId, command.roomId, command.memberUserId);
|
||||
}
|
||||
|
||||
@GrpcMethod('ChatService', 'ListMessages')
|
||||
listChatMessages(command: { userId: string; roomId: string; beforeMessageId?: string; limit?: number }) {
|
||||
return this.chat.listMessages(command.userId, command.roomId, command.beforeMessageId, command.limit);
|
||||
@@ -823,6 +855,21 @@ export class AuthGrpcController {
|
||||
return this.chat.setRoomNotificationsMuted(command.userId, command.roomId, command.muted);
|
||||
}
|
||||
|
||||
@GrpcMethod('ChatService', 'EditMessage')
|
||||
editChatMessage(command: { userId: string; messageId: string; content: string }) {
|
||||
return this.chat.editMessage(command.userId, command.messageId, command.content);
|
||||
}
|
||||
|
||||
@GrpcMethod('ChatService', 'DeleteMessage')
|
||||
deleteChatMessage(command: { userId: string; messageId: string }) {
|
||||
return this.chat.deleteMessage(command.userId, command.messageId);
|
||||
}
|
||||
|
||||
@GrpcMethod('ChatService', 'MarkRoomRead')
|
||||
markChatRoomRead(command: { userId: string; roomId: string; lastMessageId?: string }) {
|
||||
return this.chat.markRoomRead(command.userId, command.roomId, command.lastMessageId);
|
||||
}
|
||||
|
||||
private toSessionResponse(session: { id: string; userId: string; deviceId: string | null; pinVerified: boolean; status: string; ipAddress: string | null; userAgent: string | null; expiresAt: Date; createdAt: Date; updatedAt: Date }) {
|
||||
return {
|
||||
id: session.id,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -128,11 +128,11 @@ export class MediaService {
|
||||
throw new ForbiddenException('Ссылка недействительна');
|
||||
}
|
||||
|
||||
if (payload.objectKey.startsWith('chat/')) {
|
||||
if (payload.objectKey.startsWith('chat/') || payload.objectKey.startsWith('chat-rooms/')) {
|
||||
if (!requesterId) {
|
||||
throw new ForbiddenException('Для доступа к файлу чата требуется авторизация');
|
||||
}
|
||||
const roomId = payload.roomId ?? this.extractChatRoomId(payload.objectKey);
|
||||
const roomId = payload.roomId ?? this.extractChatRoomId(payload.objectKey) ?? this.extractChatRoomAvatarRoomId(payload.objectKey);
|
||||
if (!roomId) {
|
||||
throw new ForbiddenException('Некорректный ключ файла чата');
|
||||
}
|
||||
@@ -238,6 +238,39 @@ export class MediaService {
|
||||
return this.createStreamAccessUrl(storageKey, requesterId, { roomId, fileName });
|
||||
}
|
||||
|
||||
async createChatRoomAvatarUploadUrl(requesterId: string, roomId: string, contentType: string) {
|
||||
await this.assertChatMember(roomId, requesterId);
|
||||
try {
|
||||
this.minio.assertImageContentType(contentType);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error instanceof Error ? error.message : 'Некорректный тип файла');
|
||||
}
|
||||
const extension = this.minio.extensionForContentType(contentType);
|
||||
const storageKey = `chat-rooms/${roomId}/${crypto.randomUUID()}.${extension}`;
|
||||
return this.createUploadTarget(requesterId, storageKey, contentType);
|
||||
}
|
||||
|
||||
async confirmChatRoomAvatar(requesterId: string, roomId: string, storageKey: string) {
|
||||
await this.assertChatMember(roomId, requesterId);
|
||||
if (!storageKey.startsWith(`chat-rooms/${roomId}/`)) {
|
||||
throw new BadRequestException('Некорректный ключ аватара чата');
|
||||
}
|
||||
await this.prisma.chatRoom.update({
|
||||
where: { id: roomId },
|
||||
data: { avatarStorageKey: storageKey, hasAvatar: true }
|
||||
});
|
||||
return { roomId, hasAvatar: true };
|
||||
}
|
||||
|
||||
async getChatRoomAvatarAccessUrl(requesterId: string, roomId: string) {
|
||||
await this.assertChatMember(roomId, requesterId);
|
||||
const room = await this.prisma.chatRoom.findUnique({ where: { id: roomId } });
|
||||
if (!room?.avatarStorageKey) {
|
||||
throw new NotFoundException('Аватар чата не найден');
|
||||
}
|
||||
return this.createStreamAccessUrl(room.avatarStorageKey, requesterId, { roomId });
|
||||
}
|
||||
|
||||
private extractChatRoomId(storageKey: string) {
|
||||
const [prefix, roomId] = storageKey.split('/');
|
||||
if (prefix !== 'chat' || !roomId) {
|
||||
@@ -246,6 +279,14 @@ export class MediaService {
|
||||
return roomId;
|
||||
}
|
||||
|
||||
private extractChatRoomAvatarRoomId(storageKey: string) {
|
||||
const [prefix, roomId] = storageKey.split('/');
|
||||
if (prefix !== 'chat-rooms' || !roomId) {
|
||||
return null;
|
||||
}
|
||||
return roomId;
|
||||
}
|
||||
|
||||
private async createUploadTarget(userId: string, storageKey: string, contentType: string) {
|
||||
const viaApi = this.config.get<string>('MINIO_UPLOAD_VIA_API', 'true') !== 'false';
|
||||
const expiresAt = new Date(Date.now() + UPLOAD_TTL_SECONDS * 1000).toISOString();
|
||||
|
||||
44
apps/sso-core/src/domain/presence.service.ts
Normal file
44
apps/sso-core/src/domain/presence.service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { RedisService } from '../infra/redis.service';
|
||||
import { FamilyService } from './family.service';
|
||||
|
||||
const ONLINE_KEY_PREFIX = 'ws:user:';
|
||||
const LAST_SEEN_KEY_PREFIX = 'ws:last_seen:';
|
||||
|
||||
@Injectable()
|
||||
export class PresenceService {
|
||||
constructor(
|
||||
private readonly redis: RedisService,
|
||||
private readonly family: FamilyService
|
||||
) {}
|
||||
|
||||
async isUserOnline(userId: string) {
|
||||
const value = await this.redis.client.get(`${ONLINE_KEY_PREFIX}${userId}`);
|
||||
return value === 'online';
|
||||
}
|
||||
|
||||
async getUserLastSeen(userId: string) {
|
||||
return this.redis.client.get(`${LAST_SEEN_KEY_PREFIX}${userId}`);
|
||||
}
|
||||
|
||||
async getFamilyPresence(requesterId: string, groupId: string) {
|
||||
const group = await this.family.assertFamilyMember(groupId, requesterId);
|
||||
const members = await Promise.all(
|
||||
group.members.map(async (member) => {
|
||||
const online = await this.isUserOnline(member.userId);
|
||||
const lastSeenRaw = online ? null : await this.getUserLastSeen(member.userId);
|
||||
return {
|
||||
userId: member.userId,
|
||||
displayName: member.user.displayName,
|
||||
online,
|
||||
lastSeenAt: online ? new Date().toISOString() : lastSeenRaw ?? undefined
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
members,
|
||||
onlineCount: members.filter((member) => member.online).length
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user