sendou.ink/app/features/sendouq/actions/q.looking.server.ts
2026-01-03 13:47:32 +02:00

308 lines
7.9 KiB
TypeScript

import type { ActionFunction } from "react-router";
import { redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { notify } from "~/features/notifications/core/notify.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import {
createMatchMemento,
matchMapList,
} from "~/features/sendouq-match/core/match.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { SENDOUQ_PAGE, sendouQMatchPage } from "~/utils/urls";
import { groupAfterMorph } from "../core/groups";
import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
import * as PrivateUserNoteRepository from "../PrivateUserNoteRepository.server";
import { lookingSchema } from "../q-schemas.server";
import { resolveFutureMatchModes } from "../q-utils";
import { SendouQError } from "../q-utils.server";
// this function doesn't throw normally because we are assuming
// if there is a validation error the user saw stale data
// and when we return null we just force a refresh
export const action: ActionFunction = async ({ request }) => {
const user = requireUser();
const data = await parseRequestPayload({
request,
schema: lookingSchema,
});
const currentGroup = SendouQ.findOwnGroup(user.id);
if (!currentGroup) return null;
try {
// this throws because there should normally be no way user loses ownership by the action of some other user
const validateIsGroupOwner = () =>
errorToastIfFalsy(currentGroup.usersRole === "OWNER", "Not owner");
const isGroupManager = () =>
currentGroup.usersRole === "MANAGER" ||
currentGroup.usersRole === "OWNER";
switch (data._action) {
case "LIKE": {
if (!isGroupManager()) return null;
await SQGroupRepository.addLike({
likerGroupId: currentGroup.id,
targetGroupId: data.targetGroupId,
});
const targetChatCode = SendouQ.findUncensoredGroupById(
data.targetGroupId,
)?.chatCode;
if (targetChatCode) {
ChatSystemMessage.send({
room: targetChatCode,
type: "LIKE_RECEIVED",
revalidateOnly: true,
});
}
break;
}
case "RECHALLENGE": {
if (!isGroupManager()) return null;
await SQGroupRepository.rechallenge({
likerGroupId: currentGroup.id,
targetGroupId: data.targetGroupId,
});
const targetChatCode = SendouQ.findUncensoredGroupById(
data.targetGroupId,
)?.chatCode;
if (targetChatCode) {
ChatSystemMessage.send({
room: targetChatCode,
type: "LIKE_RECEIVED",
revalidateOnly: true,
});
}
break;
}
case "UNLIKE": {
if (!isGroupManager()) return null;
await SQGroupRepository.deleteLike({
likerGroupId: currentGroup.id,
targetGroupId: data.targetGroupId,
});
break;
}
case "GROUP_UP": {
if (!isGroupManager()) return null;
const allLikes = await SQGroupRepository.allLikesByGroupId(
data.targetGroupId,
);
if (!allLikes.given.some((like) => like.groupId === currentGroup.id)) {
return null;
}
const ourGroup = SendouQ.findOwnGroup(user.id);
const theirGroup = SendouQ.findUncensoredGroupById(data.targetGroupId);
if (!ourGroup || !theirGroup) return null;
const { id: survivingGroupId } = groupAfterMorph({
liker: "THEM",
ourGroup,
theirGroup,
});
const otherGroup =
ourGroup.id === survivingGroupId ? theirGroup : ourGroup;
await SQGroupRepository.morphGroups({
survivingGroupId,
otherGroupId: otherGroup.id,
});
await refreshSendouQInstance();
if (ourGroup.chatCode && theirGroup.chatCode) {
ChatSystemMessage.send([
{
room: ourGroup.chatCode,
type: "NEW_GROUP",
revalidateOnly: true,
},
{
room: theirGroup.chatCode,
type: "NEW_GROUP",
revalidateOnly: true,
},
]);
}
break;
}
case "MATCH_UP": {
if (!isGroupManager()) return null;
const ownGroup = SendouQ.findOwnGroup(user.id);
const theirGroup = SendouQ.findUncensoredGroupById(data.targetGroupId);
if (!ownGroup || !theirGroup) return null;
const ownGroupPreferences =
await SQGroupRepository.mapModePreferencesByGroupId(ownGroup.id);
const theirGroupPreferences =
await SQGroupRepository.mapModePreferencesByGroupId(theirGroup.id);
const modesIncluded = resolveFutureMatchModes(ownGroup, theirGroup);
const mapList = await matchMapList(
{
id: ownGroup.id,
preferences: ownGroupPreferences,
},
{
id: theirGroup.id,
preferences: theirGroupPreferences,
},
modesIncluded,
);
const createdMatch = await SQMatchRepository.create({
alphaGroupId: ownGroup.id,
bravoGroupId: theirGroup.id,
mapList,
memento: createMatchMemento({
own: { group: ownGroup, preferences: ownGroupPreferences },
their: { group: theirGroup, preferences: theirGroupPreferences },
mapList,
}),
});
await refreshSendouQInstance();
if (ownGroup.chatCode && theirGroup.chatCode) {
ChatSystemMessage.send([
{
room: ownGroup.chatCode,
type: "MATCH_STARTED",
revalidateOnly: true,
},
{
room: theirGroup.chatCode,
type: "MATCH_STARTED",
revalidateOnly: true,
},
]);
}
notify({
userIds: [
...ownGroup.members.map((m) => m.id),
...theirGroup.members.map((m) => m.id),
],
defaultSeenUserIds: [user.id],
notification: {
type: "SQ_NEW_MATCH",
meta: {
matchId: createdMatch.id,
},
},
});
throw redirect(sendouQMatchPage(createdMatch.id));
}
case "GIVE_MANAGER": {
validateIsGroupOwner();
await SQGroupRepository.updateMemberRole({
groupId: currentGroup.id,
userId: data.userId,
role: "MANAGER",
});
await refreshSendouQInstance();
break;
}
case "REMOVE_MANAGER": {
validateIsGroupOwner();
await SQGroupRepository.updateMemberRole({
groupId: currentGroup.id,
userId: data.userId,
role: "REGULAR",
});
await refreshSendouQInstance();
break;
}
case "LEAVE_GROUP": {
await SQGroupRepository.leaveGroup(user.id);
await refreshSendouQInstance();
const targetChatCode = SendouQ.findUncensoredGroupById(
currentGroup.id,
)?.chatCode;
if (targetChatCode) {
ChatSystemMessage.send({
room: targetChatCode,
type: "USER_LEFT",
context: { name: user.username },
});
}
throw redirect(SENDOUQ_PAGE);
}
case "KICK_FROM_GROUP": {
validateIsGroupOwner();
errorToastIfFalsy(data.userId !== user.id, "Can't kick yourself");
await SQGroupRepository.leaveGroup(data.userId);
await refreshSendouQInstance();
break;
}
case "REFRESH_GROUP": {
await SQGroupRepository.refreshGroup(currentGroup.id);
await refreshSendouQInstance();
break;
}
case "UPDATE_NOTE": {
await SQGroupRepository.updateMemberNote({
groupId: currentGroup.id,
userId: user.id,
value: data.value,
});
await refreshSendouQInstance();
break;
}
case "DELETE_PRIVATE_USER_NOTE": {
await PrivateUserNoteRepository.del({
authorId: user.id,
targetId: data.targetId,
});
break;
}
default: {
assertUnreachable(data);
}
}
return null;
} catch (error) {
// some errors are expected to happen, for example they might request two groups at the same time
// then after morphing one group the other request fails because the group no longer exists
// return null causes loaders to run and they see the fresh state again instead of error page
if (error instanceof SendouQError) {
return null;
}
throw error;
}
};