v3/packages/api/handlers/update-roles.ts

147 lines
4.1 KiB
TypeScript

import { memberPassesAccessControl } from '@roleypoly/api/utils/access-control';
import { accessControlViolation } from '@roleypoly/api/utils/responses';
import {
GuildData,
Member,
Role,
RoleSafety,
RoleTransaction,
RoleUpdate,
SessionData,
TransactionType,
} from '@roleypoly/types';
import { difference, groupBy, keyBy, union } from 'lodash';
import { AuthType, discordFetch, respond, withSession } from '../utils/api-tools';
import { botToken } from '../utils/config';
import {
getGuild,
getGuildData,
getGuildMember,
updateGuildMember,
} from '../utils/guild';
const notFound = () => respond({ error: 'guild not found' }, { status: 404 });
export const UpdateRoles = withSession(
({ guilds, user: { id: userID, username, discriminator } }: SessionData) =>
async (request: Request) => {
const updateRequest = (await request.json()) as RoleUpdate;
const url = new URL(request.url);
const [, , guildID] = url.pathname.split('/');
if (!guildID) {
return respond({ error: 'guild ID missing from URL' }, { status: 400 });
}
if (updateRequest.transactions.length === 0) {
return respond({ error: 'must have as least one transaction' }, { status: 400 });
}
const guildCheck = guilds.find((guild) => guild.id === guildID);
if (!guildCheck) {
return notFound();
}
const guild = await getGuild(guildID);
if (!guild) {
return notFound();
}
const guildMember = await getGuildMember(
{ serverID: guildID, userID },
{ skipCachePull: true }
);
if (!guildMember) {
return notFound();
}
const guildData = await getGuildData(guildID);
if (!memberPassesAccessControl(guildCheck, guildMember, guildData.accessControl)) {
return accessControlViolation();
}
const newRoles = calculateNewRoles({
currentRoles: guildMember.roles,
guildRoles: guild.roles,
guildData,
updateRequest,
});
const patchMemberRoles = await discordFetch<Member>(
`/guilds/${guildID}/members/${userID}`,
botToken,
AuthType.Bot,
{
method: 'PATCH',
headers: {
'content-type': 'application/json',
'x-audit-log-reason': `${username}#${discriminator} changes their roles via ${url.hostname}`,
},
body: JSON.stringify({
roles: newRoles,
}),
}
);
if (!patchMemberRoles) {
return respond({ error: 'discord rejected the request' }, { status: 500 });
}
const updatedMember: Member = {
roles: patchMemberRoles.roles,
};
// Delete the cache by re-pulling... might be dangerous :)
await updateGuildMember({ serverID: guildID, userID });
return respond(updatedMember);
}
);
const calculateNewRoles = ({
currentRoles,
guildData,
guildRoles,
updateRequest,
}: {
currentRoles: string[];
guildRoles: Role[];
guildData: GuildData;
updateRequest: RoleUpdate;
}): string[] => {
const roleMap = keyBy(guildRoles, 'id');
// These roles were ones changed between knownState (role picker page load/cache) and current (fresh from discord).
// We could cause issues, so we'll re-add them later.
// const diffRoles = difference(updateRequest.knownState, currentRoles);
// Only these are safe
const allSafeRoles = guildData.categories.reduce<string[]>(
(categorizedRoles, category) =>
!category.hidden
? [
...categorizedRoles,
...category.roles.filter(
(roleID) => roleMap[roleID]?.safety === RoleSafety.Safe
),
]
: categorizedRoles,
[]
);
const safeTransactions = updateRequest.transactions.filter((tx: RoleTransaction) =>
allSafeRoles.includes(tx.id)
);
const changesByAction = groupBy(safeTransactions, 'action');
const rolesToAdd = (changesByAction[TransactionType.Add] ?? []).map((tx) => tx.id);
const rolesToRemove = (changesByAction[TransactionType.Remove] ?? []).map(
(tx) => tx.id
);
const final = union(difference(currentRoles, rolesToRemove), rolesToAdd);
return final;
};