Files
fluxer/fluxer_api/src/api/user/services/UserAccountService.ts
T

255 lines
10 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ApiContext} from '@app/api/ApiContext';
import type {SudoVerificationResult} from '@app/api/auth/services/SudoVerificationService';
import type {IConnectionRepository} from '@app/api/connection/IConnectionRepository';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {GuildService} from '@app/api/guild/services/GuildService';
import {GuildMemberSearchIndexService} from '@app/api/guild/services/member/GuildMemberSearchIndexService';
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {EntityAssetService} from '@app/api/infrastructure/EntityAssetService';
import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAccountDeletionQueueService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {Logger} from '@app/api/Logger';
import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
import type {AuthSession} from '@app/api/models/AuthSession';
import type {User} from '@app/api/models/User';
import {enqueueStripeCustomerEmailSync} from '@app/api/stripe/StripeCustomer';
import type {IUserAccountRepository} from '@app/api/user/repositories/IUserAccountRepository';
import type {IUserChannelRepository} from '@app/api/user/repositories/IUserChannelRepository';
import type {IUserRelationshipRepository} from '@app/api/user/repositories/IUserRelationshipRepository';
import type {IUserSettingsRepository} from '@app/api/user/repositories/IUserSettingsRepository';
import {UserAccountLifecycleService} from '@app/api/user/services/UserAccountLifecycleService';
import {UserAccountLookupService} from '@app/api/user/services/UserAccountLookupService';
import {UserAccountNotesService} from '@app/api/user/services/UserAccountNotesService';
import {UserAccountProfileService} from '@app/api/user/services/UserAccountProfileService';
import {
type AuthSessionReplacement,
UserAccountSecurityService,
} from '@app/api/user/services/UserAccountSecurityService';
import {UserAccountSettingsService} from '@app/api/user/services/UserAccountSettingsService';
import {UserAccountUpdatePropagator} from '@app/api/user/services/UserAccountUpdatePropagator';
import type {UserContactChangeLogService} from '@app/api/user/services/UserContactChangeLogService';
import {createPremiumClearPatch} from '@app/api/user/UserHelpers';
import {hasPartialUserFieldsChanged} from '@app/api/user/UserMappers';
import {runAllInOrder} from '@app/api/utils/ConcurrencyUtils';
import {PremiumFlags} from '@fluxer/constants/src/UserConstants';
import type {UserUpdateRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
interface UpdateUserParams {
user: User;
oldAuthSession: AuthSession;
data: UserUpdateRequest;
request: Request;
sudoContext?: SudoVerificationResult;
emailVerifiedViaToken?: boolean;
}
interface UpdateUserResult {
user: User;
authSessionReplacement: AuthSessionReplacement | null;
}
interface UserAccountRepository
extends IUserAccountRepository,
IUserSettingsRepository,
IUserRelationshipRepository,
IUserChannelRepository {}
export class UserAccountService {
readonly lookupService: UserAccountLookupService;
private readonly profileService: UserAccountProfileService;
private readonly securityService: UserAccountSecurityService;
readonly settingsService: UserAccountSettingsService;
readonly notesService: UserAccountNotesService;
readonly lifecycleService: UserAccountLifecycleService;
readonly updatePropagator: UserAccountUpdatePropagator;
private readonly userAccountRepository: UserAccountRepository;
private readonly guildRepository: IGuildRepositoryAggregate;
private readonly searchIndexService: GuildMemberSearchIndexService;
constructor(
private readonly apiContext: ApiContext,
userCacheService: UserCacheService,
guildService: GuildService,
entityAssetService: EntityAssetService,
guildRepository: IGuildRepositoryAggregate,
discriminatorService: IDiscriminatorService,
kvDeletionQueue: KVAccountDeletionQueueService,
private readonly contactChangeLogService: UserContactChangeLogService,
connectionRepository: IConnectionRepository,
readonly limitConfigService: LimitConfigService,
) {
const {
users: userAccountRepository,
gateway: gatewayService,
media: mediaService,
email: emailService,
rateLimit: rateLimitService,
} = this.apiContext.services;
this.userAccountRepository = userAccountRepository;
this.guildRepository = guildRepository;
this.searchIndexService = new GuildMemberSearchIndexService();
this.updatePropagator = new UserAccountUpdatePropagator({
userCacheService,
gatewayService,
mediaService,
userRepository: userAccountRepository,
guildRepository,
});
this.lookupService = new UserAccountLookupService({
userAccountRepository,
userRelationshipRepository: userAccountRepository,
userChannelRepository: userAccountRepository,
userSettingsRepository: userAccountRepository,
guildRepository,
guildService,
discriminatorService,
connectionRepository,
});
this.profileService = new UserAccountProfileService({
userAccountRepository,
guildRepository,
entityAssetService,
rateLimitService,
limitConfigService,
});
this.securityService = new UserAccountSecurityService({
apiContext: this.apiContext,
userAccountRepository,
discriminatorService,
rateLimitService,
limitConfigService,
});
this.settingsService = new UserAccountSettingsService({
userAccountRepository,
userSettingsRepository: userAccountRepository,
userRelationshipRepository: userAccountRepository,
updatePropagator: this.updatePropagator,
gatewayService,
userCacheService,
guildRepository,
limitConfigService,
});
this.notesService = new UserAccountNotesService({
userAccountRepository,
userRelationshipRepository: userAccountRepository,
updatePropagator: this.updatePropagator,
});
this.lifecycleService = new UserAccountLifecycleService({
apiContext: this.apiContext,
userAccountRepository,
guildRepository,
emailService,
updatePropagator: this.updatePropagator,
kvDeletionQueue,
});
}
async update(params: UpdateUserParams): Promise<UpdateUserResult> {
const {user, oldAuthSession, data, request, sudoContext, emailVerifiedViaToken = false} = params;
const profileResult = await this.profileService.processProfileUpdates({user, data});
const securityResult = await this.securityService.processSecurityUpdates({user, data, sudoContext});
const updates = {
...securityResult.updates,
...profileResult.updates,
};
const securityPremiumFlags = securityResult.updates.premium_flags;
if (securityPremiumFlags !== undefined && securityPremiumFlags !== null) {
const profilePremiumFlags = profileResult.updates.premium_flags ?? user.premiumFlags;
updates.premium_flags = profilePremiumFlags | (securityPremiumFlags & ~user.premiumFlags);
}
const emailChanged = data.email !== undefined;
if (emailChanged) {
updates.email_verified = !!emailVerifiedViaToken;
}
let updatedUser: User;
try {
updatedUser = await this.userAccountRepository.patchUpsert(user.id, updates, user.toRow());
} catch (error) {
Logger.error(
{error, userId: user.id},
'User profile update failed with unknown commit status; retaining uploaded assets',
);
throw error;
} finally {
await securityResult.metadata.usernameReservation?.release();
}
const finalizationSteps: Array<() => Promise<unknown>> = [
() =>
this.contactChangeLogService.recordDiff({
oldUser: user,
newUser: updatedUser,
reason: 'user_requested',
actorUserId: user.id,
}),
() => enqueueStripeCustomerEmailSync(this.apiContext.services.worker, user, updatedUser),
async () => {
try {
await this.profileService.commitAssetChanges(profileResult);
} catch (error) {
Logger.error({error, userId: user.id}, 'Failed to commit asset changes after successful DB update');
}
},
() => this.updatePropagator.dispatchUserUpdate(updatedUser),
async () => {
if (hasPartialUserFieldsChanged(user, updatedUser)) {
await this.updatePropagator.propagatePartialUserChange(updatedUser);
}
},
async () => {
const nameChanged =
user.username !== updatedUser.username ||
user.discriminator !== updatedUser.discriminator ||
user.globalName !== updatedUser.globalName;
if (nameChanged) {
void this.reindexGuildMembersForUser(updatedUser);
}
},
];
let authSessionReplacement: AuthSessionReplacement | null = null;
if (securityResult.metadata.invalidateAuthSessions) {
finalizationSteps.push(
async () => {
authSessionReplacement = await this.securityService.invalidateAndRecreateSessions({
user,
oldAuthSession,
request,
});
},
() => this.userAccountRepository.deleteAllPasswordResetTokens(user.id),
);
}
await runAllInOrder(finalizationSteps, 'Failed to finalize user update');
return {user: updatedUser, authSessionReplacement};
}
private async reindexGuildMembersForUser(updatedUser: User): Promise<void> {
try {
const guildIds = await this.userAccountRepository.getUserGuildIds(updatedUser.id);
await this.searchIndexService.updateUserMembers(updatedUser, guildIds, this.guildRepository);
} catch (error) {
Logger.error({userId: updatedUser.id.toString(), error}, 'Failed to reindex guild members after user update');
}
}
async resetCurrentUserPremiumState(user: User): Promise<void> {
const updates = {
...createPremiumClearPatch(),
premium_lifetime_sequence: null,
stripe_subscription_id: null,
stripe_customer_id: null,
has_ever_purchased: null,
first_refund_at: null,
gift_inventory_server_seq: null,
gift_inventory_client_seq: null,
premium_flags: user.premiumFlags & ~PremiumFlags.ENABLED_OVERRIDE,
};
const updatedUser = await this.userAccountRepository.patchUpsert(user.id, updates, user.toRow());
await this.updatePropagator.dispatchUserUpdate(updatedUser);
if (hasPartialUserFieldsChanged(user, updatedUser)) {
await this.updatePropagator.propagatePartialUserChange(updatedUser);
}
}
}