Files
fx-test/fluxer_api/src/user/services/UserRelationshipService.ts

472 lines
15 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserID} from '~/BrandedTypes';
import {MAX_RELATIONSHIPS, RelationshipTypes, UserFlags} from '~/Constants';
import {
AlreadyFriendsError,
BotsCannotHaveFriendsError,
CannotSendFriendRequestToBlockedUserError,
CannotSendFriendRequestToSelfError,
FriendRequestBlockedError,
InvalidDiscriminatorError,
MaxRelationshipsError,
NoUsersWithFluxertagError,
UnclaimedAccountRestrictedError,
UnknownUserError,
} from '~/Errors';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import type {Relationship} from '~/Models';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import {type FriendRequestByTagRequest, mapRelationshipToResponse} from '~/user/UserModel';
import type {UserPermissionUtils} from '~/utils/UserPermissionUtils';
import type {IUserAccountRepository} from '../repositories/IUserAccountRepository';
import type {IUserRelationshipRepository} from '../repositories/IUserRelationshipRepository';
import {getCachedUserPartialResponse} from '../UserCacheHelpers';
export class UserRelationshipService {
constructor(
private userAccountRepository: IUserAccountRepository,
private userRelationshipRepository: IUserRelationshipRepository,
private gatewayService: IGatewayService,
private userPermissionUtils: UserPermissionUtils,
) {}
async getRelationships(userId: UserID): Promise<Array<Relationship>> {
return await this.userRelationshipRepository.listRelationships(userId);
}
async sendFriendRequestByTag({
userId,
data,
userCacheService,
requestCache,
}: {
userId: UserID;
data: FriendRequestByTagRequest;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
const {username, discriminator} = data;
const discrimValue = Number.parseInt(discriminator, 10);
if (Number.isNaN(discrimValue) || discrimValue < 0 || discrimValue > 9999) {
throw new InvalidDiscriminatorError();
}
const targetUser = await this.userAccountRepository.findByUsernameDiscriminator(username, discrimValue);
if (!targetUser) {
throw new NoUsersWithFluxertagError();
}
const existingRelationship = await this.userRelationshipRepository.getRelationship(
userId,
targetUser.id,
RelationshipTypes.FRIEND,
);
if (existingRelationship) {
throw new AlreadyFriendsError();
}
return this.sendFriendRequest({userId, targetId: targetUser.id, userCacheService, requestCache});
}
async sendFriendRequest({
userId,
targetId,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
await this.validateFriendRequest({userId, targetId});
const pendingIncoming = await this.userRelationshipRepository.getRelationship(
targetId,
userId,
RelationshipTypes.OUTGOING_REQUEST,
);
if (pendingIncoming) {
return this.acceptFriendRequest({userId, targetId, userCacheService, requestCache});
}
const existingFriendship = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.FRIEND,
);
const existingOutgoingRequest = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.OUTGOING_REQUEST,
);
if (existingFriendship || existingOutgoingRequest) {
const relationships = await this.userRelationshipRepository.listRelationships(userId);
const relationship = relationships.find((r) => r.targetUserId === targetId);
if (relationship) {
return relationship;
}
}
await this.validateRelationshipCounts({userId, targetId});
return await this.createFriendRequest({userId, targetId, userCacheService, requestCache});
}
async acceptFriendRequest({
userId,
targetId,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
const user = await this.userAccountRepository.findUnique(userId);
if (user && user.isUnclaimedAccount()) {
throw new UnclaimedAccountRestrictedError('accept friend requests');
}
const incomingRequest = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.INCOMING_REQUEST,
);
if (!incomingRequest) {
throw new UnknownUserError();
}
await this.validateRelationshipCounts({userId, targetId});
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.INCOMING_REQUEST);
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.OUTGOING_REQUEST);
const now = new Date();
const userRelationship = await this.userRelationshipRepository.upsertRelationship({
source_user_id: userId,
target_user_id: targetId,
type: RelationshipTypes.FRIEND,
nickname: null,
since: now,
version: 1,
});
const targetRelationship = await this.userRelationshipRepository.upsertRelationship({
source_user_id: targetId,
target_user_id: userId,
type: RelationshipTypes.FRIEND,
nickname: null,
since: now,
version: 1,
});
await this.dispatchRelationshipUpdate({
userId,
relationship: userRelationship,
userCacheService,
requestCache,
});
await this.dispatchRelationshipUpdate({
userId: targetId,
relationship: targetRelationship,
userCacheService,
requestCache,
});
return userRelationship;
}
async blockUser({
userId,
targetId,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
const targetUser = await this.userAccountRepository.findUnique(targetId);
if (!targetUser) {
throw new UnknownUserError();
}
const existingBlocked = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.BLOCKED,
);
if (existingBlocked) {
return existingBlocked;
}
const existingFriend = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.FRIEND,
);
const existingIncomingRequest = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.INCOMING_REQUEST,
);
const existingOutgoingRequest = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.OUTGOING_REQUEST,
);
if (existingFriend) {
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.FRIEND);
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.FRIEND);
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
} else if (existingOutgoingRequest) {
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST);
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.INCOMING_REQUEST);
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
} else if (existingIncomingRequest) {
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.INCOMING_REQUEST);
}
const now = new Date();
const blockRelationship = await this.userRelationshipRepository.upsertRelationship({
source_user_id: userId,
target_user_id: targetId,
type: RelationshipTypes.BLOCKED,
nickname: null,
since: now,
version: 1,
});
await this.dispatchRelationshipCreate({
userId,
relationship: blockRelationship,
userCacheService,
requestCache,
});
return blockRelationship;
}
async removeRelationship({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
const existingRelationship =
(await this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.FRIEND)) ||
(await this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.INCOMING_REQUEST)) ||
(await this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST)) ||
(await this.userRelationshipRepository.getRelationship(userId, targetId, RelationshipTypes.BLOCKED));
if (!existingRelationship) throw new UnknownUserError();
const relationshipType = existingRelationship.type;
if (relationshipType === RelationshipTypes.INCOMING_REQUEST || relationshipType === RelationshipTypes.BLOCKED) {
await this.userRelationshipRepository.deleteRelationship(userId, targetId, relationshipType);
await this.dispatchRelationshipRemove({
userId,
targetId: targetId.toString(),
});
return;
}
if (relationshipType === RelationshipTypes.OUTGOING_REQUEST) {
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.OUTGOING_REQUEST);
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.INCOMING_REQUEST);
await this.dispatchRelationshipRemove({userId, targetId: targetId.toString()});
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
return;
}
if (relationshipType === RelationshipTypes.FRIEND) {
await this.userRelationshipRepository.deleteRelationship(userId, targetId, RelationshipTypes.FRIEND);
await this.userRelationshipRepository.deleteRelationship(targetId, userId, RelationshipTypes.FRIEND);
await this.dispatchRelationshipRemove({userId, targetId: targetId.toString()});
await this.dispatchRelationshipRemove({userId: targetId, targetId: userId.toString()});
return;
}
await this.userRelationshipRepository.deleteRelationship(userId, targetId, relationshipType);
await this.dispatchRelationshipRemove({userId, targetId: targetId.toString()});
}
async updateFriendNickname({
userId,
targetId,
nickname,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
nickname: string | null;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
const relationship = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.FRIEND,
);
if (!relationship) {
throw new UnknownUserError();
}
const updatedRelationship = await this.userRelationshipRepository.upsertRelationship({
source_user_id: userId,
target_user_id: targetId,
type: RelationshipTypes.FRIEND,
nickname,
since: relationship.since ?? new Date(),
version: 1,
});
await this.dispatchRelationshipUpdate({
userId,
relationship: updatedRelationship,
userCacheService,
requestCache,
});
return updatedRelationship;
}
private async validateFriendRequest({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
if (userId === targetId) {
throw new CannotSendFriendRequestToSelfError();
}
const requesterUser = await this.userAccountRepository.findUnique(userId);
if (requesterUser && requesterUser.isUnclaimedAccount()) {
throw new UnclaimedAccountRestrictedError('send friend requests');
}
const targetUser = await this.userAccountRepository.findUnique(targetId);
if (!targetUser) throw new UnknownUserError();
if (targetUser.isBot) {
throw new BotsCannotHaveFriendsError();
}
if (targetUser.flags & UserFlags.APP_STORE_REVIEWER) {
throw new FriendRequestBlockedError();
}
const requesterBlockedTarget = await this.userRelationshipRepository.getRelationship(
userId,
targetId,
RelationshipTypes.BLOCKED,
);
if (requesterBlockedTarget) {
throw new CannotSendFriendRequestToBlockedUserError();
}
const targetBlockedRequester = await this.userRelationshipRepository.getRelationship(
targetId,
userId,
RelationshipTypes.BLOCKED,
);
if (targetBlockedRequester) {
throw new FriendRequestBlockedError();
}
await this.userPermissionUtils.validateFriendSourcePermissions({userId, targetId});
}
private async validateRelationshipCounts({userId, targetId}: {userId: UserID; targetId: UserID}): Promise<void> {
const relationships = await this.userRelationshipRepository.listRelationships(userId);
if (relationships.length >= MAX_RELATIONSHIPS) {
throw new MaxRelationshipsError();
}
const targetRelationships = await this.userRelationshipRepository.listRelationships(targetId);
if (targetRelationships.length >= MAX_RELATIONSHIPS) {
throw new MaxRelationshipsError();
}
}
private async createFriendRequest({
userId,
targetId,
userCacheService,
requestCache,
}: {
userId: UserID;
targetId: UserID;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<Relationship> {
const now = new Date();
const userRelationship = await this.userRelationshipRepository.upsertRelationship({
source_user_id: userId,
target_user_id: targetId,
type: RelationshipTypes.OUTGOING_REQUEST,
nickname: null,
since: now,
version: 1,
});
const targetRelationship = await this.userRelationshipRepository.upsertRelationship({
source_user_id: targetId,
target_user_id: userId,
type: RelationshipTypes.INCOMING_REQUEST,
nickname: null,
since: now,
version: 1,
});
await this.dispatchRelationshipCreate({userId, relationship: userRelationship, userCacheService, requestCache});
await this.dispatchRelationshipCreate({
userId: targetId,
relationship: targetRelationship,
userCacheService,
requestCache,
});
return userRelationship;
}
async dispatchRelationshipCreate({
userId,
relationship,
userCacheService,
requestCache,
}: {
userId: UserID;
relationship: Relationship;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
const userPartialResolver = (userId: UserID) =>
getCachedUserPartialResponse({userId, userCacheService, requestCache});
await this.gatewayService.dispatchPresence({
userId,
event: 'RELATIONSHIP_ADD',
data: await mapRelationshipToResponse({relationship, userPartialResolver}),
});
}
async dispatchRelationshipUpdate({
userId,
relationship,
userCacheService,
requestCache,
}: {
userId: UserID;
relationship: Relationship;
userCacheService: UserCacheService;
requestCache: RequestCache;
}): Promise<void> {
const userPartialResolver = (userId: UserID) =>
getCachedUserPartialResponse({userId, userCacheService, requestCache});
await this.gatewayService.dispatchPresence({
userId,
event: 'RELATIONSHIP_UPDATE',
data: await mapRelationshipToResponse({relationship, userPartialResolver}),
});
}
async dispatchRelationshipRemove({userId, targetId}: {userId: UserID; targetId: string}): Promise<void> {
await this.gatewayService.dispatchPresence({
userId,
event: 'RELATIONSHIP_REMOVE',
data: {id: targetId},
});
}
}