From 1a59972398c54b175377142bbba392f50972f784 Mon Sep 17 00:00:00 2001 From: Katalina Okano Date: Fri, 18 Dec 2020 19:55:58 -0500 Subject: [PATCH] feat: support updating roles (closes #83) --- package.json | 2 + src/backend-worker/handlers/update-roles.ts | 141 ++++++++++++++++++ src/backend-worker/index.ts | 2 + src/backend-worker/router.ts | 3 - src/backend-worker/utils/api-tools.ts | 15 +- src/backend-worker/utils/guild.ts | 16 +- src/common/types/Role.ts | 16 ++ .../organisms/role-picker/RolePicker.tsx | 18 +-- src/pages/s/[id].tsx | 77 +++++++++- yarn.lock | 5 + 10 files changed, 272 insertions(+), 23 deletions(-) create mode 100644 src/backend-worker/handlers/update-roles.ts diff --git a/package.json b/package.json index a3a5200..7539f95 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "chroma-js": "^2.1.0", "isomorphic-unfetch": "^3.1.0", "ksuid": "^2.0.0", + "lodash": "^4.17.20", "next": "^10.0.3", "nookies": "^2.5.0", "react": "^17.0.1", @@ -60,6 +61,7 @@ "@types/enzyme-adapter-react-16": "^1.0.6", "@types/express": "^4.17.9", "@types/jest": "^26.0.19", + "@types/lodash": "^4.14.165", "@types/minimist": "^1.2.1", "@types/node": "^14.14.14", "@types/react": "^17.0.0", diff --git a/src/backend-worker/handlers/update-roles.ts b/src/backend-worker/handlers/update-roles.ts new file mode 100644 index 0000000..9a9c94b --- /dev/null +++ b/src/backend-worker/handlers/update-roles.ts @@ -0,0 +1,141 @@ +import { difference, groupBy, keyBy, union } from 'lodash'; +import { + GuildData, + Member, + Role, + RoleSafety, + RoleTransaction, + RoleUpdate, + SessionData, + TransactionType, +} from 'roleypoly/common/types'; +import { AuthType, discordFetch, respond, withSession } from '../utils/api-tools'; +import { botToken } from '../utils/config'; +import { + getGuild, + getGuildData, + getGuildMemberRoles, + updateGuildMemberRoles, +} from '../utils/guild'; + +const notFound = () => respond({ error: 'guild not found' }, { status: 404 }); + +export const UpdateRoles = withSession( + ({ guilds, user: { id: userID } }: SessionData) => async (request: Request) => { + const updateRequest = (await request.json()) as RoleUpdate; + const [, , guildID] = new URL(request.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 guildMemberRoles = await getGuildMemberRoles( + { serverID: guildID, userID }, + { skipCachePull: true } + ); + if (!guildMemberRoles) { + return notFound(); + } + + const newRoles = calculateNewRoles({ + currentRoles: guildMemberRoles, + guildRoles: guild.roles, + guildData: await getGuildData(guildID), + updateRequest, + }); + + const patchMemberRoles = await discordFetch( + `/guilds/${guildID}/members/${userID}`, + botToken, + AuthType.Bot, + { + method: 'PATCH', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + roles: newRoles, + }), + } + ); + + if (!patchMemberRoles) { + return respond({ error: 'discord rejected the request' }, { status: 500 }); + } + + const updatedMember: Member = { + roles: patchMemberRoles.roles, + }; + + await updateGuildMemberRoles( + { serverID: guildID, userID }, + patchMemberRoles.roles + ); + + 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( + (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; +}; diff --git a/src/backend-worker/index.ts b/src/backend-worker/index.ts index a8fbd5c..6db467b 100644 --- a/src/backend-worker/index.ts +++ b/src/backend-worker/index.ts @@ -6,6 +6,7 @@ import { GetSlug } from './handlers/get-slug'; import { LoginBounce } from './handlers/login-bounce'; import { LoginCallback } from './handlers/login-callback'; import { RevokeSession } from './handlers/revoke-session'; +import { UpdateRoles } from './handlers/update-roles'; import { Router } from './router'; import { respond } from './utils/api-tools'; import { uiPublicURI } from './utils/config'; @@ -24,6 +25,7 @@ router.add('POST', 'revoke-session', RevokeSession); // Main biz logic router.add('GET', 'get-slug', GetSlug); router.add('GET', 'get-picker-data', GetPickerData); +router.add('PATCH', 'update-roles', UpdateRoles); // Root users only router.add('GET', 'x-create-roleypoly-data', CreateRoleypolyData); diff --git a/src/backend-worker/router.ts b/src/backend-worker/router.ts index 91f5859..363d434 100644 --- a/src/backend-worker/router.ts +++ b/src/backend-worker/router.ts @@ -52,9 +52,6 @@ export class Router { if (handler) { try { const response = await handler(request); - - // this.wrapCORS(request, response); - return response; } catch (e) { console.error(e); diff --git a/src/backend-worker/utils/api-tools.ts b/src/backend-worker/utils/api-tools.ts index c56e6f1..d87d398 100644 --- a/src/backend-worker/utils/api-tools.ts +++ b/src/backend-worker/utils/api-tools.ts @@ -18,7 +18,7 @@ export const addCORS = (init: ResponseInit = {}) => ({ headers: { ...(init.headers || {}), 'access-control-allow-origin': uiPublicURI, - 'access-control-allow-method': '*', + 'access-control-allow-methods': '*', 'access-control-allow-headers': '*', }, }); @@ -80,15 +80,26 @@ export enum AuthType { export const discordFetch = async ( url: string, auth: string, - authType: AuthType = AuthType.Bearer + authType: AuthType = AuthType.Bearer, + init?: RequestInit ): Promise => { const response = await fetch('https://discord.com/api/v8' + url, { + ...(init || {}), headers: { + ...(init?.headers || {}), authorization: `${AuthType[authType]} ${auth}`, 'user-agent': userAgent, }, }); + if (response.status >= 400) { + console.error('discordFetch failed', { + url, + authType, + payload: await response.text(), + }); + } + if (response.ok) { return (await response.json()) as T; } else { diff --git a/src/backend-worker/utils/guild.ts b/src/backend-worker/utils/guild.ts index 40db870..f51d804 100644 --- a/src/backend-worker/utils/guild.ts +++ b/src/backend-worker/utils/guild.ts @@ -7,7 +7,7 @@ import { RoleSafety, } from 'roleypoly/common/types'; import { evaluatePermission, permissions } from 'roleypoly/common/utils/hasPermission'; -import { cacheLayer, discordFetch } from './api-tools'; +import { AuthType, cacheLayer, discordFetch } from './api-tools'; import { botClientID, botToken } from './config'; import { GuildData, Guilds } from './kv'; @@ -92,14 +92,17 @@ type APIMember = { roles: string[]; }; +const guildMemberRolesIdentity = ({ serverID, userID }) => + `guilds/${serverID}/members/${userID}`; + export const getGuildMemberRoles = cacheLayer( Guilds, - ({ serverID, userID }) => `guilds/${serverID}/members/${userID}`, + guildMemberRolesIdentity, async ({ serverID, userID }) => { const discordMember = await discordFetch( `/guilds/${serverID}/members/${userID}`, botToken, - 'Bot' + AuthType.Bot ); if (!discordMember) { @@ -111,6 +114,13 @@ export const getGuildMemberRoles = cacheLayer 60 * 5 // 5 minute TTL ); +export const updateGuildMemberRoles = async ( + identity: GuildMemberIdentity, + roles: Role['id'][] +) => { + await Guilds.put(guildMemberRolesIdentity(identity), roles, 60 * 5); +}; + export const getGuildData = async (id: string): Promise => { const guildData = await GuildData.get(id); diff --git a/src/common/types/Role.ts b/src/common/types/Role.ts index 35ec4d8..3d89688 100644 --- a/src/common/types/Role.ts +++ b/src/common/types/Role.ts @@ -19,3 +19,19 @@ export type Role = { export type OwnRoleInfo = { highestRolePosition: number; }; + +export enum TransactionType { + None = 0, + Remove = 1 << 1, + Add = 1 << 2, +} + +export type RoleTransaction = { + id: string; + action: TransactionType; +}; + +export type RoleUpdate = { + knownState: Role['id'][]; + transactions: RoleTransaction[]; +}; diff --git a/src/design-system/organisms/role-picker/RolePicker.tsx b/src/design-system/organisms/role-picker/RolePicker.tsx index 27c53d2..c0918b7 100644 --- a/src/design-system/organisms/role-picker/RolePicker.tsx +++ b/src/design-system/organisms/role-picker/RolePicker.tsx @@ -1,3 +1,4 @@ +import { isEqual, xor } from 'lodash'; import NextLink from 'next/link'; import * as React from 'react'; import { GoInfo } from 'react-icons/go'; @@ -34,20 +35,17 @@ export type RolePickerProps = { editable: boolean; }; -const arrayMatches = (a: any[], b: any[]) => { - return ( - a === b || - (a.length === b.length && - a.every((x) => b.includes(x)) && - b.every((x) => a.includes(x))) - ); -}; - export const RolePicker = (props: RolePickerProps) => { const [selectedRoles, updateSelectedRoles] = React.useState( props.member.roles ); + React.useEffect(() => { + if (!isEqual(props.member.roles, selectedRoles)) { + updateSelectedRoles(props.member.roles); + } + }, [props.member.roles]); + const handleChange = (category: Category) => (role: Role) => (newState: boolean) => { if (category.type === CategoryType.Single) { updateSelectedRoles( @@ -114,7 +112,7 @@ export const RolePicker = (props: RolePickerProps) => { )} props.onSubmit(selectedRoles)} diff --git a/src/pages/s/[id].tsx b/src/pages/s/[id].tsx index 9ed6cc3..bd9628c 100644 --- a/src/pages/s/[id].tsx +++ b/src/pages/s/[id].tsx @@ -1,7 +1,15 @@ import { NextPage, NextPageContext } from 'next'; import Head from 'next/head'; import * as React from 'react'; -import { PresentableGuild, UserGuildPermissions } from 'roleypoly/common/types'; +import { + Member, + PresentableGuild, + Role, + RoleTransaction, + RoleUpdate, + TransactionType, + UserGuildPermissions, +} from 'roleypoly/common/types'; import { apiFetch } from 'roleypoly/common/utils/isomorphicFetch'; import { RolePickerTemplate } from 'roleypoly/design-system/templates/role-picker'; import { useAppShellProps } from 'roleypoly/providers/appShellData'; @@ -10,9 +18,70 @@ type Props = { data: PresentableGuild; }; +const createUpdatePayload = ( + oldRoles: Role['id'][], + newRoles: Role['id'][] +): RoleTransaction[] => { + const transactions: RoleTransaction[] = []; + + // Removes: old roles not in new roles + for (let oldID of oldRoles) { + if (!newRoles.includes(oldID)) { + transactions.push({ + id: oldID, + action: TransactionType.Remove, + }); + } + } + + // Adds: new roles not in old roles + for (let newID of newRoles) { + if (!oldRoles.includes(newID)) { + transactions.push({ + id: newID, + action: TransactionType.Add, + }); + } + } + + return transactions; +}; + const RolePickerPage: NextPage = (props) => { const { appShellProps } = useAppShellProps(); + const [isPending, updatePending] = React.useState(false); + const [memberRoles, updateMemberRoles] = React.useState(props.data.member.roles); + + const handlePickerSubmit = (guildID: string, oldRoles: Role['id'][]) => async ( + newRoles: Role['id'][] + ) => { + if (isPending) { + return; + } + + updatePending(true); + + const payload: RoleUpdate = { + knownState: oldRoles, + transactions: createUpdatePayload(oldRoles, newRoles), + }; + + const patchedMember = await apiFetch(`/update-roles/${guildID}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }); + + if (!patchedMember) { + console.error('role update failed', patchedMember); + return; + } + + updatePending(false); + updateMemberRoles(patchedMember.roles); + console.log('accepted', { patchedMember }); + }; + return ( <> @@ -24,12 +93,10 @@ const RolePickerPage: NextPage = (props) => { guild={props.data.guild} roles={props.data.roles} guildData={props.data.data} - member={props.data.member} + member={{ ...props.data.member, roles: memberRoles }} editable={props.data.guild.permissionLevel !== UserGuildPermissions.User} activeGuildId={props.data.id} - onSubmit={(i) => { - console.log(i); - }} + onSubmit={handlePickerSubmit(props.data.id, memberRoles)} /> ); diff --git a/yarn.lock b/yarn.lock index 8a1b98c..3608eef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2519,6 +2519,11 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/lodash@^4.14.165": + version "4.14.165" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.165.tgz#74d55d947452e2de0742bad65270433b63a8c30f" + integrity sha512-tjSSOTHhI5mCHTy/OOXYIhi2Wt1qcbHmuXD1Ha7q70CgI/I71afO4XtLb/cVexki1oVYchpul/TOuu3Arcdxrg== + "@types/markdown-to-jsx@^6.11.0": version "6.11.3" resolved "https://registry.yarnpkg.com/@types/markdown-to-jsx/-/markdown-to-jsx-6.11.3.tgz#cdd1619308fecbc8be7e6a26f3751260249b020e"