// SPDX-License-Identifier: AGPL-3.0-or-later import type {ApiContext} from '@app/api/ApiContext'; import {mapUserToAdminResponse} from '@app/api/admin/models/UserTypes'; import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService'; import type {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator'; import {createUserID, type UserID} from '@app/api/BrandedTypes'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; import {GuildMemberSearchIndexService} from '@app/api/guild/services/member/GuildMemberSearchIndexService'; import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService'; import type {EntityAssetService, PreparedAssetUpload} from '@app/api/infrastructure/EntityAssetService'; import {Logger} from '@app/api/Logger'; import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons'; import type {User} from '@app/api/models/User'; import {enqueueStripeCustomerEmailSync} from '@app/api/stripe/StripeCustomer'; import {assertNoDiscriminatorChange, reserveUsername, type UsernameReservation} from '@app/api/user/UniqueUsernames'; import {USERNAME_MODE_DISCRIMINATOR} from '@app/api/user/UserTag'; import {TagAlreadyTakenError} from '@fluxer/errors/src/domains/user/TagAlreadyTakenError'; import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError'; import type { ChangeDobRequest, ChangeEmailRequest, ChangeUsernameRequest, ClearUserFieldsRequest, VerifyUserEmailRequest, } from '@fluxer/schema/src/domains/admin/AdminUserSchemas'; import {types} from 'cassandra-driver'; interface AdminUserProfileServiceDeps { apiContext: ApiContext; discriminatorService: IDiscriminatorService; entityAssetService: EntityAssetService; auditService: AdminAuditService; updatePropagator: AdminUserUpdatePropagator; guildRepository: IGuildRepositoryAggregate; } export class AdminUserProfileService { private readonly searchIndexService: GuildMemberSearchIndexService; constructor(private readonly deps: AdminUserProfileServiceDeps) { this.searchIndexService = new GuildMemberSearchIndexService(); } async clearUserFields( data: ClearUserFieldsRequest, adminUserId: UserID, auditLogReason: string | null, acls: ReadonlySet, ) { const {users: userRepository, cache: cacheService} = this.deps.apiContext.services; const {entityAssetService, auditService, updatePropagator} = this.deps; const userId = createUserID(data.user_id); const user = await userRepository.findUnique(userId); if (!user) { throw new UnknownUserError(); } const updates: Record = {}; const preparedAssets: Array = []; for (const field of data.fields) { if (field === 'avatar') { const prepared = await entityAssetService.prepareAssetUpload({ assetType: 'avatar', entityType: 'user', entityId: userId, previousHash: user.avatarHash, base64Image: null, errorPath: 'avatar', }); preparedAssets.push(prepared); updates['avatar_hash'] = prepared.newHash; } else if (field === 'banner') { const prepared = await entityAssetService.prepareAssetUpload({ assetType: 'banner', entityType: 'user', entityId: userId, previousHash: user.bannerHash, base64Image: null, errorPath: 'banner', }); preparedAssets.push(prepared); updates['banner_hash'] = prepared.newHash; } else if (field === 'bio') { updates['bio'] = null; } else if (field === 'pronouns') { updates['pronouns'] = null; } else if (field === 'global_name') { updates['global_name'] = null; } } const updatedUser = await userRepository.patchUpsert(userId, updates, user.toRow()); await entityAssetService.commitAssetChanges(preparedAssets); await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser}); await auditService.createAuditLog({ adminUserId, targetType: 'user', targetId: BigInt(userId), action: 'clear_fields', auditLogReason, metadata: new Map([['fields', data.fields.join(',')]]), }); return { user: await mapUserToAdminResponse(updatedUser, cacheService, acls), }; } async verifyUserEmail( data: VerifyUserEmailRequest, adminUserId: UserID, auditLogReason: string | null, acls: ReadonlySet, ) { const {users: userRepository, cache: cacheService} = this.deps.apiContext.services; const {auditService, updatePropagator} = this.deps; const userId = createUserID(data.user_id); const user = await userRepository.findUnique(userId); if (!user) { throw new UnknownUserError(); } const updatedUser = await userRepository.patchUpsert( userId, {email_verified: true, email_bounced: false}, user.toRow(), ); await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser}); await auditService.createAuditLog({ adminUserId, targetType: 'user', targetId: BigInt(userId), action: 'verify_email', auditLogReason, metadata: new Map([['email', user.email ?? 'null']]), }); return { user: await mapUserToAdminResponse(updatedUser, cacheService, acls), }; } async changeUsername( data: ChangeUsernameRequest, adminUserId: UserID, auditLogReason: string | null, acls: ReadonlySet, ) { const { users: userRepository, cache: cacheService, contactChangeLog: contactChangeLogService, } = this.deps.apiContext.services; const {discriminatorService, auditService, updatePropagator} = this.deps; const userId = createUserID(data.user_id); const user = await userRepository.findUnique(userId); if (!user) { throw new UnknownUserError(); } const uniqueUsernames = !user.isBot && (await getInstanceConfigRepository().usesUniqueUsernames()); if (uniqueUsernames) { assertNoDiscriminatorChange(data.discriminator, user.discriminator); } const reservation: UsernameReservation | null = uniqueUsernames ? await reserveUsername({users: userRepository, cache: cacheService}, data.username, userId) : null; let updatedUser: User; let discriminatorResult: {discriminator: number; available: boolean}; try { discriminatorResult = uniqueUsernames ? {discriminator: USERNAME_MODE_DISCRIMINATOR, available: true} : await discriminatorService.generateDiscriminator({ username: data.username, requestedDiscriminator: data.discriminator, user, }); if (!discriminatorResult.available || discriminatorResult.discriminator === -1) { throw new TagAlreadyTakenError(); } updatedUser = await userRepository.patchUpsert( userId, { username: data.username, discriminator: discriminatorResult.discriminator, }, user.toRow(), ); } finally { await reservation?.release(); } await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser}); await contactChangeLogService.recordDiff({ oldUser: user, newUser: updatedUser, reason: 'admin_action', actorUserId: adminUserId, }); await auditService.createAuditLog({ adminUserId, targetType: 'user', targetId: BigInt(userId), action: 'change_username', auditLogReason, metadata: new Map([ ['old_username', user.username], ['new_username', data.username], ['discriminator', discriminatorResult.discriminator.toString()], ]), }); void this.reindexGuildMembersForUser(updatedUser); return { user: await mapUserToAdminResponse(updatedUser, cacheService, acls), }; } async changeEmail( data: ChangeEmailRequest, adminUserId: UserID, auditLogReason: string | null, acls: ReadonlySet, ) { const { users: userRepository, cache: cacheService, contactChangeLog: contactChangeLogService, worker: workerService, } = this.deps.apiContext.services; const {auditService, updatePropagator} = this.deps; const userId = createUserID(data.user_id); const user = await userRepository.findUnique(userId); if (!user) { throw new UnknownUserError(); } const updatedUser = await userRepository.patchUpsert( userId, { email: data.email, email_verified: false, }, user.toRow(), ); await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser}); await contactChangeLogService.recordDiff({ oldUser: user, newUser: updatedUser, reason: 'admin_action', actorUserId: adminUserId, }); await enqueueStripeCustomerEmailSync(workerService, user, updatedUser); await auditService.createAuditLog({ adminUserId, targetType: 'user', targetId: BigInt(userId), action: 'change_email', auditLogReason, metadata: new Map([ ['old_email', user.email ?? 'null'], ['new_email', data.email], ]), }); return { user: await mapUserToAdminResponse(updatedUser, cacheService, acls), }; } private async reindexGuildMembersForUser(updatedUser: User): Promise { try { const {users: userRepository} = this.deps.apiContext.services; const {guildRepository} = this.deps; const guildIds = await userRepository.getUserGuildIds(updatedUser.id); await this.searchIndexService.updateUserMembers(updatedUser, guildIds, guildRepository); } catch (error) { Logger.error( {userId: updatedUser.id.toString(), error}, 'Failed to reindex guild members after admin user update', ); } } async changeDob( data: ChangeDobRequest, adminUserId: UserID, auditLogReason: string | null, acls: ReadonlySet, ) { const {users: userRepository, cache: cacheService} = this.deps.apiContext.services; const {auditService, updatePropagator} = this.deps; const userId = createUserID(data.user_id); const user = await userRepository.findUnique(userId); if (!user) { throw new UnknownUserError(); } const updatedUser = await userRepository.patchUpsert( userId, { date_of_birth: types.LocalDate.fromString(data.date_of_birth), }, user.toRow(), ); await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser}); await auditService.createAuditLog({ adminUserId, targetType: 'user', targetId: BigInt(userId), action: 'change_dob', auditLogReason, metadata: new Map([ ['old_dob', user.dateOfBirth ?? 'null'], ['new_dob', data.date_of_birth], ]), }); return { user: await mapUserToAdminResponse(updatedUser, cacheService, acls), }; } }