- 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
190 lines
7.5 KiB
TypeScript
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;
|
|
}
|
|
}
|