Messenger/packages/backend/src/modules/conversations/conversations.service.ts
Заид Омар Медхат | Zaid Omar Medhat db5c1610b3 Phase 6: media messages + avatars via MinIO presigned uploads
- Media (voice/video/image/file): private bucket, presigned PUT upload,
  reference-only publish, membership-gated presigned GET for viewing
- Avatars: public bucket, presigned PUT + direct public URL, profile.update event
- messages.media_key + media_meta (migration 1720000000005_media)
- Explicit MinIO region on presigning client (avoids getBucketRegion network call)
- Event type values sourced from EventType constants (no raw string literals)
- Web: attach button, MediaView renderer, avatar upload in topbar

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFRyKxkKEjfAgpXygzoNiD
2026-07-10 18:11:25 +05:00

190 lines
7.5 KiB
TypeScript

import type {
Conversation,
ConversationMember,
ConversationNewEvent,
ConversationMembershipEvent,
ReadReceiptEvent,
} from '@altricade/core';
import { userChannel, EventType } from '@altricade/core';
import { HttpError } from '../../shared/http-error';
import type { Publisher } from '../../shared/publisher';
import { toPublicUser } from '../users';
import type { UsersRepository } from '../users';
import type { ConversationsRepository, DeliveryInfo } from './conversations.repository';
import type { ReadStateRepository } from './read-state.repository';
import type { Deliver } from './delivery';
import { toConversation, toMember, memberToPublicUser } from './conversations.mapper';
export interface ConversationsServiceDeps {
conversations: ConversationsRepository;
users: UsersRepository;
readState: ReadStateRepository;
deliver: Deliver;
publish: Publisher;
}
export interface ConversationsService {
createDirect(actorId: string, username: string): Promise<Conversation>;
createGroup(actorId: string, title: string, memberUsernames: string[]): Promise<Conversation>;
list(userId: string): Promise<Conversation[]>;
get(conversationId: string, userId: string): Promise<Conversation>;
listMembers(conversationId: string, userId: string): Promise<ConversationMember[]>;
addMember(conversationId: string, actorId: string, username: string): Promise<ConversationMember[]>;
removeMember(conversationId: string, actorId: string, targetUserId: string): Promise<void>;
isMember(conversationId: string, userId: string): Promise<boolean>;
getDeliveryInfo(conversationId: string): Promise<DeliveryInfo | undefined>;
markRead(conversationId: string, userId: string, seq: number): Promise<void>;
}
export const createConversationsService = (
deps: ConversationsServiceDeps,
): ConversationsService => {
const { conversations, users, readState, deliver, publish } = deps;
const assertMember = async (conversationId: string, userId: string): Promise<void> => {
if (!(await conversations.isMember(conversationId, userId))) {
throw new HttpError(403, 'not_a_member', 'You are not a member of this conversation');
}
};
const assertOwner = async (conversationId: string, userId: string): Promise<void> => {
if ((await conversations.getRole(conversationId, userId)) !== 'owner') {
throw new HttpError(403, 'not_owner', 'Only the group owner can do this');
}
};
const conversationNew = (conversation: Conversation): ConversationNewEvent => ({
type: EventType.ConversationNew,
conversation,
});
const membership = (
action: ConversationMembershipEvent['action'],
conversationId: string,
userId: string,
): ConversationMembershipEvent => ({ type: EventType.ConversationMembership, action, conversationId, userId });
return {
createDirect: async (actorId, username) => {
const target = await users.findByUsername(username);
if (target === undefined) {
throw new HttpError(404, 'user_not_found', 'No such user');
}
if (target.id === actorId) {
throw new HttpError(400, 'invalid_target', 'Cannot start a direct chat with yourself');
}
const targetPublic = toPublicUser(target);
const { conversation, created } = await conversations.findOrCreateDirect(actorId, target.id);
if (created) {
const actorRow = await users.findById(actorId);
const actorPublic = actorRow === undefined ? null : toPublicUser(actorRow);
await publish(
userChannel(target.id),
conversationNew(toConversation(conversation, actorPublic)),
);
await publish(
userChannel(actorId),
conversationNew(toConversation(conversation, targetPublic)),
);
}
return toConversation(conversation, targetPublic);
},
createGroup: async (actorId, title, memberUsernames) => {
const memberIds: string[] = [];
for (const username of memberUsernames) {
const found = await users.findByUsername(username);
if (found !== undefined && found.id !== actorId) {
memberIds.push(found.id);
}
}
const conversation = toConversation(
await conversations.createGroup(title, actorId, memberIds),
null,
);
for (const userId of [actorId, ...memberIds]) {
await publish(userChannel(userId), conversationNew(conversation));
}
return conversation;
},
list: async (userId) => {
const rows = await conversations.listForUser(userId);
const directIds = rows.filter((row) => row.type === 'direct').map((row) => row.id);
const peers = await conversations.peersForDirect(userId, directIds);
const unread = await readState.unreadCounts(userId);
return rows.map((row) => {
const count = unread.get(row.id) ?? 0;
if (row.type !== 'direct') {
return toConversation(row, null, count);
}
const peer = peers.get(row.id);
return toConversation(row, peer === undefined ? null : memberToPublicUser(peer), count);
});
},
get: async (conversationId, userId) => {
await assertMember(conversationId, userId);
const conversation = await conversations.findById(conversationId);
if (conversation === undefined) {
throw new HttpError(404, 'not_found', 'Conversation not found');
}
const count = await readState.unreadFor(conversationId, userId);
if (conversation.type !== 'direct') {
return toConversation(conversation, null, count);
}
const peer = await conversations.getPeer(conversationId, userId);
return toConversation(
conversation,
peer === undefined ? null : memberToPublicUser(peer),
count,
);
},
listMembers: async (conversationId, userId) => {
await assertMember(conversationId, userId);
return (await conversations.listMembers(conversationId)).map(toMember);
},
addMember: async (conversationId, actorId, username) => {
await assertOwner(conversationId, actorId);
const target = await users.findByUsername(username);
if (target === undefined) {
throw new HttpError(404, 'user_not_found', 'No such user');
}
await conversations.addMember(conversationId, target.id, 'member');
const conversation = await conversations.findById(conversationId);
if (conversation !== undefined) {
await publish(userChannel(target.id), conversationNew(toConversation(conversation, null)));
}
await publish(userChannel(target.id), membership('added', conversationId, target.id));
return (await conversations.listMembers(conversationId)).map(toMember);
},
removeMember: async (conversationId, actorId, targetUserId) => {
if (actorId !== targetUserId) {
await assertOwner(conversationId, actorId);
}
await conversations.removeMember(conversationId, targetUserId);
await publish(userChannel(targetUserId), membership('removed', conversationId, targetUserId));
},
isMember: (conversationId, userId) => conversations.isMember(conversationId, userId),
getDeliveryInfo: (conversationId) => conversations.getDeliveryInfo(conversationId),
markRead: async (conversationId, userId, seq) => {
await assertMember(conversationId, userId);
await readState.setRead(conversationId, userId, seq);
const event: ReadReceiptEvent = { type: EventType.ReadReceipt, conversationId, userId, seq };
await deliver(conversationId, event);
},
};
};
declare module 'fastify' {
interface FastifyInstance {
conversationsService: ConversationsService;
}
}