Files
fluxer/fluxer_api/src/api/admin/services/AdminUserProfileService.ts
T

313 lines
10 KiB
TypeScript

// 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<string>,
) {
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<string, null | string> = {};
const preparedAssets: Array<PreparedAssetUpload> = [];
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<string>,
) {
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<string>,
) {
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<string>,
) {
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<void> {
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<string>,
) {
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),
};
}
}