mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-04-24 19:39:11 +00:00
feat: support updating roles (closes #83)
This commit is contained in:
parent
c7381c3d66
commit
1a59972398
10 changed files with 272 additions and 23 deletions
|
@ -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",
|
||||
|
|
141
src/backend-worker/handlers/update-roles.ts
Normal file
141
src/backend-worker/handlers/update-roles.ts
Normal file
|
@ -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<Member>(
|
||||
`/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<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;
|
||||
};
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 <T>(
|
||||
url: string,
|
||||
auth: string,
|
||||
authType: AuthType = AuthType.Bearer
|
||||
authType: AuthType = AuthType.Bearer,
|
||||
init?: RequestInit
|
||||
): Promise<T | null> => {
|
||||
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 {
|
||||
|
|
|
@ -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<GuildMemberIdentity, Role['id'][]>(
|
||||
Guilds,
|
||||
({ serverID, userID }) => `guilds/${serverID}/members/${userID}`,
|
||||
guildMemberRolesIdentity,
|
||||
async ({ serverID, userID }) => {
|
||||
const discordMember = await discordFetch<APIMember>(
|
||||
`/guilds/${serverID}/members/${userID}`,
|
||||
botToken,
|
||||
'Bot'
|
||||
AuthType.Bot
|
||||
);
|
||||
|
||||
if (!discordMember) {
|
||||
|
@ -111,6 +114,13 @@ export const getGuildMemberRoles = cacheLayer<GuildMemberIdentity, Role['id'][]>
|
|||
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<GuildDataT> => {
|
||||
const guildData = await GuildData.get<GuildDataT>(id);
|
||||
|
||||
|
|
|
@ -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[];
|
||||
};
|
||||
|
|
|
@ -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<string[]>(
|
||||
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) => {
|
|||
)}
|
||||
</div>
|
||||
<FaderOpacity
|
||||
isVisible={!arrayMatches(selectedRoles, props.member.roles)}
|
||||
isVisible={xor(selectedRoles, props.member.roles).length !== 0}
|
||||
>
|
||||
<ResetSubmit
|
||||
onSubmit={() => props.onSubmit(selectedRoles)}
|
||||
|
|
|
@ -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> = (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<Member>(`/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 (
|
||||
<>
|
||||
<Head>
|
||||
|
@ -24,12 +93,10 @@ const RolePickerPage: NextPage<Props> = (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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Reference in a new issue