fix(user): push guild member updates on profile field changes (#2850)

This commit is contained in:
Hampus
2026-09-19 23:30:25 +02:00
committed by GitHub
parent ac3450ab32
commit bf7567b768
8 changed files with 223 additions and 42 deletions
@@ -1,14 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UserID} from '@app/api/BrandedTypes';
import {mapGuildMemberToResponse} from '@app/api/guild/GuildModel';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {User} from '@app/api/models/User';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {BaseUserUpdatePropagator} from '@app/api/user/services/BaseUserUpdatePropagator';
import {propagatePartialUserChange} from '@app/api/user/services/PartialUserChangePropagation';
import {hasPartialUserFieldsChanged} from '@app/api/user/UserMappers';
interface AdminUserUpdatePropagatorDeps {
@@ -26,41 +25,11 @@ export class AdminUserUpdatePropagator extends BaseUserUpdatePropagator {
});
}
async propagateUserUpdate({
userId,
oldUser,
updatedUser,
}: {
userId: UserID;
oldUser: User;
updatedUser: User;
}): Promise<void> {
async propagateUserUpdate(params: {userId: UserID; oldUser: User; updatedUser: User}): Promise<void> {
const {oldUser, updatedUser} = params;
await this.dispatchUserUpdate(updatedUser);
if (hasPartialUserFieldsChanged(oldUser, updatedUser)) {
await this.updateUserCache(updatedUser);
await this.propagateToGuilds(userId);
await propagatePartialUserChange(this.deps, updatedUser);
}
}
private async propagateToGuilds(userId: UserID): Promise<void> {
const {userRepository, guildRepository, gatewayService, userCacheService} = this.deps;
const guildIds = await userRepository.getUserGuildIds(userId);
if (guildIds.length === 0) {
return;
}
const requestCache = createRequestCache();
for (const guildId of guildIds) {
const member = await guildRepository.getMember(guildId, userId);
if (!member) {
continue;
}
const memberResponse = await mapGuildMemberToResponse(member, userCacheService, requestCache);
await gatewayService.dispatchGuild({
guildId,
event: 'GUILD_MEMBER_UPDATE',
data: memberResponse,
});
}
requestCache.clear();
}
}
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {GuildID, UserID} from '@app/api/BrandedTypes';
import {mapGuildMemberToResponse} from '@app/api/guild/GuildModel';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {User} from '@app/api/models/User';
import {updateUserCache} from '@app/api/user/UserCacheHelpers';
interface UserGuildIdReader {
getUserGuildIds(userId: UserID): Promise<Array<GuildID>>;
}
export interface PartialUserChangePropagationDeps {
userCacheService: UserCacheService;
gatewayService: IGatewayService;
userRepository: UserGuildIdReader;
guildRepository: Pick<IGuildRepositoryAggregate, 'getMember'>;
}
export async function propagatePartialUserChange(deps: PartialUserChangePropagationDeps, user: User): Promise<void> {
const {userCacheService, gatewayService, userRepository, guildRepository} = deps;
await updateUserCache({user, userCacheService});
const guildIds = await userRepository.getUserGuildIds(user.id);
if (guildIds.length === 0) {
return;
}
const requestCache = createRequestCache();
for (const guildId of guildIds) {
const member = await guildRepository.getMember(guildId, user.id);
if (!member) {
continue;
}
const memberResponse = await mapGuildMemberToResponse(member, userCacheService, requestCache);
await gatewayService.dispatchGuild({
guildId,
event: 'GUILD_MEMBER_UPDATE',
data: memberResponse,
});
}
requestCache.clear();
}
@@ -84,7 +84,7 @@ export class UserAccountLifecycleService {
if (updatedUser) {
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser);
if (hasPartialUserFieldsChanged(user, updatedUser)) {
await this.deps.updatePropagator.updateUserCache(updatedUser);
await this.deps.updatePropagator.propagatePartialUserChange(updatedUser);
}
}
}
@@ -125,7 +125,7 @@ export class UserAccountLifecycleService {
if (updatedUser) {
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser);
if (hasPartialUserFieldsChanged(user, updatedUser)) {
await this.deps.updatePropagator.updateUserCache(updatedUser);
await this.deps.updatePropagator.propagatePartialUserChange(updatedUser);
}
}
}
@@ -87,6 +87,7 @@ export class UserAccountService {
gatewayService,
mediaService,
userRepository: userAccountRepository,
guildRepository,
});
this.lookupService = new UserAccountLookupService({
userAccountRepository,
@@ -193,7 +194,7 @@ export class UserAccountService {
() => this.updatePropagator.dispatchUserUpdate(updatedUser),
async () => {
if (hasPartialUserFieldsChanged(user, updatedUser)) {
await this.updatePropagator.updateUserCache(updatedUser);
await this.updatePropagator.propagatePartialUserChange(updatedUser);
}
},
async () => {
@@ -240,7 +241,7 @@ export class UserAccountService {
const updatedUser = await this.userAccountRepository.patchUpsert(user.id, updates, user.toRow());
await this.updatePropagator.dispatchUserUpdate(updatedUser);
if (hasPartialUserFieldsChanged(user, updatedUser)) {
await this.updatePropagator.updateUserCache(updatedUser);
await this.updatePropagator.propagatePartialUserChange(updatedUser);
}
}
}
@@ -1,13 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UserID} from '@app/api/BrandedTypes';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {IMediaService} from '@app/api/infrastructure/IMediaService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import type {User} from '@app/api/models/User';
import type {UserGuildSettings} from '@app/api/models/UserGuildSettings';
import type {UserSettings} from '@app/api/models/UserSettings';
import type {IUserAccountRepository} from '@app/api/user/repositories/IUserAccountRepository';
import {BaseUserUpdatePropagator} from '@app/api/user/services/BaseUserUpdatePropagator';
import {propagatePartialUserChange} from '@app/api/user/services/PartialUserChangePropagation';
import {mapUserGuildSettingsToResponse, mapUserSettingsToResponse} from '@app/api/user/UserMappers';
interface UserAccountUpdatePropagatorDeps {
@@ -15,6 +18,7 @@ interface UserAccountUpdatePropagatorDeps {
gatewayService: IGatewayService;
mediaService: IMediaService;
userRepository: IUserAccountRepository;
guildRepository: IGuildRepositoryAggregate;
}
export class UserAccountUpdatePropagator extends BaseUserUpdatePropagator {
@@ -25,6 +29,10 @@ export class UserAccountUpdatePropagator extends BaseUserUpdatePropagator {
});
}
async propagatePartialUserChange(user: User): Promise<void> {
await propagatePartialUserChange(this.deps, user);
}
async dispatchUserSettingsUpdate({userId, settings}: {userId: UserID; settings: UserSettings}): Promise<void> {
await this.deps.gatewayService.dispatchPresence({
userId,
@@ -0,0 +1,99 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import type {GuildMember} from '@app/api/models/GuildMember';
import type {User} from '@app/api/models/User';
import {
type PartialUserChangePropagationDeps,
propagatePartialUserChange,
} from '@app/api/user/services/PartialUserChangePropagation';
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {describe, expect, test, vi} from 'vitest';
const USER_ID = createUserID(1n);
const GUILD_IDS = [createGuildID(10n), createGuildID(11n)];
const USER = {id: USER_ID} as unknown as User;
const USER_PARTIAL: UserPartialResponse = {
id: USER_ID.toString(),
username: 'ada',
discriminator: '0001',
global_name: null,
avatar: 'newhash',
avatar_color: null,
flags: 0,
};
function createMember(): GuildMember {
return {
userId: USER_ID,
nickname: null,
avatarHash: null,
bannerHash: null,
accentColor: null,
roleIds: new Set(),
joinedAt: new Date(0),
isMute: false,
isDeaf: false,
communicationDisabledUntil: null,
profileFlags: 0,
mentionFlags: 0,
isPremiumSanitized: false,
} as unknown as GuildMember;
}
function createDeps(guildIds: Array<(typeof GUILD_IDS)[number]> = GUILD_IDS) {
const dispatchGuild = vi.fn().mockResolvedValue(undefined);
const setUserPartialResponseFromUser = vi.fn().mockResolvedValue(USER_PARTIAL);
const getUserGuildIds = vi.fn().mockResolvedValue(guildIds);
const getMember = vi.fn().mockResolvedValue(createMember());
const deps: PartialUserChangePropagationDeps = {
userCacheService: {
setUserPartialResponseFromUser,
getUserPartialResponse: async () => USER_PARTIAL,
} as unknown as UserCacheService,
gatewayService: {dispatchGuild} as unknown as IGatewayService,
userRepository: {getUserGuildIds},
guildRepository: {getMember} as unknown as IGuildRepositoryAggregate,
};
return {deps, dispatchGuild, setUserPartialResponseFromUser, getUserGuildIds, getMember};
}
describe('propagatePartialUserChange', () => {
test('invalidates the users service cache and pushes the new partial to every guild', async () => {
const {deps, dispatchGuild, setUserPartialResponseFromUser} = createDeps();
await propagatePartialUserChange(deps, USER);
expect(setUserPartialResponseFromUser).toHaveBeenCalledWith(USER);
expect(dispatchGuild).toHaveBeenCalledTimes(2);
for (const [index, guildId] of GUILD_IDS.entries()) {
const call = dispatchGuild.mock.calls[index]![0];
expect(call.guildId).toBe(guildId);
expect(call.event).toBe('GUILD_MEMBER_UPDATE');
expect((call.data as {user: UserPartialResponse}).user).toEqual(USER_PARTIAL);
}
});
test('dispatches nothing when the user is in no guilds', async () => {
const {deps, dispatchGuild, getMember} = createDeps([]);
await propagatePartialUserChange(deps, USER);
expect(getMember).not.toHaveBeenCalled();
expect(dispatchGuild).not.toHaveBeenCalled();
});
test('skips guilds the user is no longer a member of', async () => {
const {deps, dispatchGuild, getMember} = createDeps();
getMember.mockResolvedValueOnce(null);
await propagatePartialUserChange(deps, USER);
expect(dispatchGuild).toHaveBeenCalledTimes(1);
expect(dispatchGuild.mock.calls[0]![0].guildId).toBe(GUILD_IDS[1]);
});
});
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestGuild, getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
import {updateAvatar} from '@app/api/user/tests/UserTestUtils';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest';
type DispatchGuildSpy = MockInstance<NoopGatewayService['dispatchGuild']>;
interface MemberUpdateDispatch {
guildId: {toString(): string};
event: string;
data: GuildMemberResponse;
}
function memberUpdatesFor(dispatchGuild: DispatchGuildSpy, guildId: string): Array<MemberUpdateDispatch> {
return dispatchGuild.mock.calls
.map(([params]) => params as unknown as MemberUpdateDispatch)
.filter((params) => params.event === 'GUILD_MEMBER_UPDATE' && params.guildId.toString() === guildId);
}
describe('User Profile Guild Propagation', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness?.shutdown();
});
it('pushes the new avatar to every guild the user is in', async () => {
const account = await createTestAccount(harness);
const guild = await createTestGuild(harness, account.token);
const dispatchGuild = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
const updated = await updateAvatar(harness, account.token, getPngDataUrl());
expect(updated.avatar).toBeTruthy();
const updates = memberUpdatesFor(dispatchGuild, guild.id);
expect(updates).toHaveLength(1);
expect(updates[0]!.data.user.id).toBe(account.userId);
expect(updates[0]!.data.user.avatar).toBe(updated.avatar);
});
it('does not push a member update when no partial field changed', async () => {
const account = await createTestAccount(harness);
const guild = await createTestGuild(harness, account.token);
const dispatchGuild = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
await updateAvatar(harness, account.token, null);
expect(memberUpdatesFor(dispatchGuild, guild.id)).toHaveLength(0);
});
});
@@ -46,9 +46,6 @@ const CURRENT_USER_PRIVATE_WIRE_KEYS = [
] as const;
function isPublicOnlyCurrentUserPayload(user: WireUser): boolean {
if (typeof user.mention_flags === 'number') {
return false;
}
return !CURRENT_USER_PRIVATE_WIRE_KEYS.some((key) => key in user);
}