diff --git a/fluxer_api/src/api/user/services/UserChannelRequestService.ts b/fluxer_api/src/api/user/services/UserChannelRequestService.ts index 1d98b3d2c..db18bea00 100644 --- a/fluxer_api/src/api/user/services/UserChannelRequestService.ts +++ b/fluxer_api/src/api/user/services/UserChannelRequestService.ts @@ -1,11 +1,14 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; import type {CreatePrivateChannelRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas'; import type {ChannelID, UserID} from '../../BrandedTypes'; import {mapChannelToResponse} from '../../channel/ChannelMappers'; import type {UserCacheService} from '../../infrastructure/UserCacheService'; import type {RequestCache} from '../../middleware/RequestCacheMiddleware'; +import type {Channel} from '../../models/Channel'; +import {getCachedUserPartialResponses} from '../UserCacheHelpers'; import type {UserChannelService} from './UserChannelService'; interface UserChannelListParams { @@ -24,6 +27,21 @@ interface UserChannelPinParams { channelId: ChannelID; } +function collectDMRecipientIds(channels: Array, currentUserId: UserID): Array { + const recipientIds = new Set(); + for (const channel of channels) { + if (channel.guildId != null || channel.type === ChannelTypes.DM_PERSONAL_NOTES) { + continue; + } + for (const recipientId of channel.recipientIds) { + if (recipientId !== currentUserId) { + recipientIds.add(recipientId); + } + } + } + return Array.from(recipientIds); +} + export class UserChannelRequestService { constructor( private readonly userChannelService: UserChannelService, @@ -32,6 +50,11 @@ export class UserChannelRequestService { async listPrivateChannels(params: UserChannelListParams): Promise> { const channels = await this.userChannelService.getPrivateChannels(params.userId); + await getCachedUserPartialResponses({ + userIds: collectDMRecipientIds(channels, params.userId), + userCacheService: this.userCacheService, + requestCache: params.requestCache, + }); return Promise.all( channels.map((channel) => mapChannelToResponse({ diff --git a/fluxer_api/src/api/user/services/UserRelationshipRequestService.ts b/fluxer_api/src/api/user/services/UserRelationshipRequestService.ts index 2e3ce8e03..9a0364017 100644 --- a/fluxer_api/src/api/user/services/UserRelationshipRequestService.ts +++ b/fluxer_api/src/api/user/services/UserRelationshipRequestService.ts @@ -16,7 +16,7 @@ import type {UserID} from '../../BrandedTypes'; import type {UserCacheService} from '../../infrastructure/UserCacheService'; import type {RequestCache} from '../../middleware/RequestCacheMiddleware'; import type {Relationship} from '../../models/Relationship'; -import {getCachedUserPartialResponse} from '../UserCacheHelpers'; +import {getCachedUserPartialResponse, getCachedUserPartialResponses} from '../UserCacheHelpers'; import {mapRelationshipToResponse} from '../UserMappers'; import type {UserChannelService} from './UserChannelService'; import type {UserRelationshipService} from './UserRelationshipService'; @@ -69,6 +69,11 @@ export class UserRelationshipRequestService { const userPartialResolver = this.createUserPartialResolver(params.requestCache); const inverseRelationshipResolver = this.createInverseRelationshipResolver(params.userId); const relationships = await this.userRelationshipService.getRelationships(params.userId); + await getCachedUserPartialResponses({ + userIds: relationships.map((relationship) => relationship.targetUserId), + userCacheService: this.userCacheService, + requestCache: params.requestCache, + }); return Promise.all( relationships.map((relationship) => mapRelationshipToResponse({relationship, userPartialResolver, inverseRelationshipResolver}), diff --git a/fluxer_api/src/api/user/tests/ListEndpointUserPartialPrefetch.test.ts b/fluxer_api/src/api/user/tests/ListEndpointUserPartialPrefetch.test.ts new file mode 100644 index 000000000..1511a9d5e --- /dev/null +++ b/fluxer_api/src/api/user/tests/ListEndpointUserPartialPrefetch.test.ts @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {DELETED_USER_GLOBAL_NAME, DELETED_USER_USERNAME, RelationshipTypes} from '@fluxer/constants/src/UserConstants'; +import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; +import {describe, expect, test} from 'vitest'; +import {type ChannelID, createChannelID, createUserID, type UserID} from '../../BrandedTypes'; +import type {ChannelRow} from '../../database/types/ChannelTypes'; +import type {RelationshipRow} from '../../database/types/UserTypes'; +import {UserCacheService} from '../../infrastructure/UserCacheService'; +import type {IUsersServiceClient} from '../../infrastructure/UsersServiceClient'; +import {createRequestCache} from '../../middleware/RequestCacheMiddleware'; +import {Channel} from '../../models/Channel'; +import {Relationship} from '../../models/Relationship'; +import {UserChannelRequestService} from '../services/UserChannelRequestService'; +import type {UserChannelService} from '../services/UserChannelService'; +import {UserRelationshipRequestService} from '../services/UserRelationshipRequestService'; +import type {UserRelationshipService} from '../services/UserRelationshipService'; + +class RecordingUsersServiceClient implements IUsersServiceClient { + readonly requests: Array> = []; + + constructor(private readonly partialsById: Map) {} + + async getUserPartialResponses(userIds: Array): Promise> { + this.requests.push([...userIds]); + const result = new Map(); + for (const userId of userIds) { + const partial = this.partialsById.get(userId); + if (partial) { + result.set(userId, partial); + } + } + return result; + } + + async invalidateUserCache(_userId: UserID): Promise {} +} + +function createPartial(userId: UserID, username: string): UserPartialResponse { + return { + id: userId.toString(), + username, + discriminator: '0001', + global_name: null, + avatar: null, + avatar_color: null, + flags: 0, + }; +} + +function createPartials(userIds: Array): Map { + return new Map(userIds.map((userId, index) => [userId, createPartial(userId, `Partial${index}`)])); +} + +function createRelationship(sourceUserId: UserID, targetUserId: UserID, shareVoiceActivity: boolean): Relationship { + return new Relationship({ + source_user_id: sourceUserId, + target_user_id: targetUserId, + type: RelationshipTypes.FRIEND, + nickname: null, + since: null, + share_voice_activity: shareVoiceActivity, + version: 1, + } satisfies RelationshipRow); +} + +function createPrivateChannel(channelId: ChannelID, type: number, recipientIds: Set): Channel { + return new Channel({ + channel_id: channelId, + guild_id: null, + type, + name: null, + topic: null, + icon_hash: null, + url: null, + parent_id: null, + position: null, + owner_id: null, + recipient_ids: recipientIds, + nsfw: null, + content_warning_level: null, + content_warning_text: null, + rate_limit_per_user: null, + bitrate: null, + user_limit: null, + voice_connection_limit: null, + rtc_region: null, + last_message_id: null, + last_pin_timestamp: null, + permission_overwrites: null, + nicks: null, + soft_deleted: false, + indexed_at: null, + version: 1, + } satisfies ChannelRow); +} + +function createRelationshipRequestService( + relationships: Array, + inverseRelationships: Map, + userCacheService: UserCacheService, +): UserRelationshipRequestService { + return new UserRelationshipRequestService( + { + getRelationships: async () => relationships, + getRelationship: async (params: {userId: UserID}) => inverseRelationships.get(params.userId) ?? null, + } as unknown as UserRelationshipService, + {} as UserChannelService, + userCacheService, + ); +} + +describe('list endpoint user partial prefetch', () => { + test('resolves every relationship from a single batched user partial fetch', async () => { + const viewerId = createUserID(9000n); + const targetIds = [createUserID(9001n), createUserID(9002n), createUserID(9003n), createUserID(9004n)]; + const partials = createPartials(targetIds); + const usersServiceClient = new RecordingUsersServiceClient(partials); + const relationships = targetIds.map((targetId) => createRelationship(viewerId, targetId, true)); + const inverseRelationships = new Map( + targetIds.map((targetId) => [targetId, createRelationship(targetId, viewerId, false)]), + ); + const service = createRelationshipRequestService( + relationships, + inverseRelationships, + new UserCacheService(usersServiceClient), + ); + + const response = await service.listRelationships({userId: viewerId, requestCache: createRequestCache()}); + + expect(usersServiceClient.requests).toEqual([targetIds]); + expect(response).toEqual( + targetIds.map((targetId) => ({ + id: targetId.toString(), + type: RelationshipTypes.FRIEND, + user: partials.get(targetId), + nickname: null, + share_voice_activity: true, + friend_shares_voice_activity: false, + })), + ); + }); + + test('keeps the deleted-user fallback for relationship targets the users service drops', async () => { + const viewerId = createUserID(9100n); + const knownId = createUserID(9101n); + const missingId = createUserID(9102n); + const usersServiceClient = new RecordingUsersServiceClient(createPartials([knownId])); + const relationships = [createRelationship(viewerId, knownId, true), createRelationship(viewerId, missingId, true)]; + const service = createRelationshipRequestService( + relationships, + new Map(), + new UserCacheService(usersServiceClient), + ); + + const response = await service.listRelationships({userId: viewerId, requestCache: createRequestCache()}); + + expect(usersServiceClient.requests).toEqual([[knownId, missingId]]); + expect(response[1]?.user).toMatchObject({ + id: missingId.toString(), + username: DELETED_USER_USERNAME, + global_name: DELETED_USER_GLOBAL_NAME, + }); + expect(response[1]?.friend_shares_voice_activity).toBe(true); + }); + + test('resolves every private channel recipient from a single batched user partial fetch', async () => { + const viewerId = createUserID(9200n); + const friendId = createUserID(9201n); + const groupMemberId = createUserID(9202n); + const partials = createPartials([friendId, groupMemberId]); + const usersServiceClient = new RecordingUsersServiceClient(partials); + const channels = [ + createPrivateChannel(createChannelID(9300n), ChannelTypes.DM, new Set([viewerId, friendId])), + createPrivateChannel(createChannelID(9301n), ChannelTypes.GROUP_DM, new Set([viewerId, friendId, groupMemberId])), + createPrivateChannel(createChannelID(9302n), ChannelTypes.DM_PERSONAL_NOTES, new Set([viewerId])), + ]; + const service = new UserChannelRequestService( + {getPrivateChannels: async () => channels} as unknown as UserChannelService, + new UserCacheService(usersServiceClient), + ); + + const response = await service.listPrivateChannels({userId: viewerId, requestCache: createRequestCache()}); + + expect(usersServiceClient.requests).toEqual([[friendId, groupMemberId]]); + expect(response[0]?.recipients).toEqual([partials.get(friendId)]); + expect(response[1]?.recipients).toEqual([partials.get(friendId), partials.get(groupMemberId)]); + expect(response[2]?.recipients).toBeUndefined(); + }); +});