From e2908a834d09be16cac64c0df16546a5954118e4 Mon Sep 17 00:00:00 2001 From: rotorsoft Date: Mon, 9 Sep 2024 17:39:20 -0400 Subject: [PATCH] first pass --- libs/adapters/src/trpc/handlers.ts | 2 +- .../src/community/CreateCommunity.command.ts | 2 +- .../UpdateCommunity.command.sample.ts | 31 -- .../src/community/UpdateCommunity.command.ts | 160 +++++++++++ libs/model/src/community/index.ts | 1 + .../community/community-lifecycle.spec.ts | 239 +++++++++++----- .../schemas/src/commands/community.schemas.ts | 10 +- .../schemas/src/entities/community.schemas.ts | 4 +- .../state/api/communities/updateCommunity.ts | 120 +++----- .../pages/AdminPanel/RPCEndpointTask.tsx | 13 +- .../CommunityProfileForm.tsx | 27 +- .../Integrations/CustomTOS/CustomTOS.tsx | 11 +- .../Integrations/Directory/Directory.tsx | 15 +- .../Integrations/Discord/Discord.tsx | 11 +- .../Integrations/Snapshots/Snapshots.tsx | 11 +- .../useReserveCommunityNamespace.tsx | 15 +- packages/commonwealth/server/api/community.ts | 27 +- .../server/api/external-router.ts | 10 +- .../server_communities_controller.ts | 11 - .../update_community.ts | 269 ------------------ .../communities/update_community_handler.ts | 36 --- .../commonwealth/server/routing/router.ts | 8 - .../test/integration/api/updateChain.spec.ts | 154 ---------- .../integration/api/updatecommunity.spec.ts | 261 ++++++++--------- packages/commonwealth/test/util/modelUtils.ts | 4 +- 25 files changed, 617 insertions(+), 835 deletions(-) delete mode 100644 libs/model/src/community/UpdateCommunity.command.sample.ts create mode 100644 libs/model/src/community/UpdateCommunity.command.ts delete mode 100644 packages/commonwealth/server/controllers/server_communities_methods/update_community.ts delete mode 100644 packages/commonwealth/server/routes/communities/update_community_handler.ts delete mode 100644 packages/commonwealth/test/integration/api/updateChain.spec.ts diff --git a/libs/adapters/src/trpc/handlers.ts b/libs/adapters/src/trpc/handlers.ts index b414fc862be..06f7dd17086 100644 --- a/libs/adapters/src/trpc/handlers.ts +++ b/libs/adapters/src/trpc/handlers.ts @@ -26,7 +26,7 @@ const trpcerror = (error: unknown): TRPCError => { default: return new TRPCError({ code: 'INTERNAL_SERVER_ERROR', - message, + message: `[${name}] ${message}`, cause: error, }); } diff --git a/libs/model/src/community/CreateCommunity.command.ts b/libs/model/src/community/CreateCommunity.command.ts index 54e1d765766..5fd79345c51 100644 --- a/libs/model/src/community/CreateCommunity.command.ts +++ b/libs/model/src/community/CreateCommunity.command.ts @@ -266,7 +266,7 @@ export function CreateCommunity(): Command { const uniqueLinksArray = [ ...new Set( [...social_links, website, telegram, discord, element, github].filter( - (a) => a, + (a): a is string => typeof a === 'string' && a.length > 0, ), ), ]; diff --git a/libs/model/src/community/UpdateCommunity.command.sample.ts b/libs/model/src/community/UpdateCommunity.command.sample.ts deleted file mode 100644 index 5e66b8587b4..00000000000 --- a/libs/model/src/community/UpdateCommunity.command.sample.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { type Command } from '@hicommonwealth/core'; -import * as schemas from '@hicommonwealth/schemas'; -import { models } from '../database'; -import { mustExist } from '../middleware/guards'; -import { commonProtocol } from '../services'; - -export function UpdateCommunity(): Command { - return { - ...schemas.UpdateCommunity, - auth: [], - body: async ({ payload }) => { - const community = await models.Community.findOne({ - where: { id: payload.id }, - }); - - mustExist('Community', community); - - const namespaceAddress = - await commonProtocol.newNamespaceValidator.validateNamespace( - payload.namespace, - payload.txHash, - payload.address, - community, - ); - - community.namespace = payload.namespace; - community.namespace_address = namespaceAddress; - return (await community.save()).get({ plain: true }); - }, - }; -} diff --git a/libs/model/src/community/UpdateCommunity.command.ts b/libs/model/src/community/UpdateCommunity.command.ts new file mode 100644 index 00000000000..0596a0d622d --- /dev/null +++ b/libs/model/src/community/UpdateCommunity.command.ts @@ -0,0 +1,160 @@ +import { InvalidInput, type Command } from '@hicommonwealth/core'; +import * as schemas from '@hicommonwealth/schemas'; +import { ALL_COMMUNITIES, ChainBase } from '@hicommonwealth/shared'; +import { models } from '../database'; +import { AuthContext, isAuthorized } from '../middleware'; +import { mustExist } from '../middleware/guards'; +import { checkSnapshotObjectExists, commonProtocol } from '../services'; + +export const UpdateCommunityErrors = { + ReservedId: 'The id is reserved and cannot be used', + CantChangeCustomDomain: 'Custom domain change not permitted', + CustomDomainIsTaken: 'Custom domain is taken by another community', + CantChangeNetwork: 'Cannot change community network', + NotAdmin: 'Not an admin', + InvalidCustomDomain: 'Custom domain may not include "commonwealth"', + SnapshotOnlyOnEthereum: + 'Snapshot data may only be added to chains with Ethereum base', + InvalidDefaultPage: 'Default page does not exist', + InvalidTransactionHash: 'Valid transaction hash required to verify namespace', + SnapshotNotFound: 'Snapshot not found', +}; + +export function UpdateCommunity(): Command< + typeof schemas.UpdateCommunity, + AuthContext +> { + return { + ...schemas.UpdateCommunity, + auth: [isAuthorized({ roles: ['admin'] })], + body: async ({ actor, payload }) => { + const { + id, + network, + custom_domain, + snapshot, + name, + description, + default_symbol, + icon_url, + active, + type, + stages_enabled, + has_homepage, + chain_node_id, + directory_page_enabled, + directory_page_chain_node_id, + default_summary_view, + default_page, + social_links, + hide_projects, + custom_stages, + namespace, + transactionHash, + } = payload; + + if (id === ALL_COMMUNITIES) + throw new InvalidInput(UpdateCommunityErrors.ReservedId); + if (network) + throw new InvalidInput(UpdateCommunityErrors.CantChangeNetwork); + + const community = await models.Community.findOne({ + where: { id }, + include: [ + { + model: models.ChainNode, + attributes: ['url', 'eth_chain_id', 'cosmos_chain_id'], + }, + ], + }); + mustExist('Community', community); // if authorized as admin, community is always found + + // Only permit site admins to update custom domain field on communities, as it requires + // external configuration (via heroku + whitelists). + // Currently does not permit unsetting the custom domain; must be done manually. + if (custom_domain && community.custom_domain !== custom_domain) { + if (!actor.user.isAdmin) + throw new InvalidInput(UpdateCommunityErrors.CantChangeCustomDomain); + if (custom_domain.includes('commonwealth')) + throw new InvalidInput(UpdateCommunityErrors.InvalidCustomDomain); + + // verify if this custom domain is taken by another community + const found = await models.Community.findOne({ + where: { custom_domain }, + }); + if (found) + throw new InvalidInput(UpdateCommunityErrors.CustomDomainIsTaken); + } + + // Handle single string case and undefined case + const snapshots = !snapshot + ? [] + : typeof snapshot === 'string' + ? [snapshot] + : snapshot; + if (snapshots.length > 0 && community.base !== ChainBase.Ethereum) + throw new InvalidInput(UpdateCommunityErrors.SnapshotOnlyOnEthereum); + + const newSpaces = snapshots.filter( + (s) => !community.snapshot_spaces.includes(s), + ); + for (const space of newSpaces) { + if (!(await checkSnapshotObjectExists('space', space))) + throw new InvalidInput(UpdateCommunityErrors.SnapshotNotFound); + } + + if (default_page && !has_homepage) + throw new InvalidInput(UpdateCommunityErrors.InvalidDefaultPage); + + if (namespace) { + if (!transactionHash) + throw new InvalidInput(UpdateCommunityErrors.InvalidTransactionHash); + + // we only permit the community admin and not the site admin to create namespace + if (actor.user.isAdmin) + throw new InvalidInput(UpdateCommunityErrors.NotAdmin); + + community.namespace = namespace; + community.namespace_address = + await commonProtocol.newNamespaceValidator.validateNamespace( + namespace!, + transactionHash, + actor.address!, + community, + ); + } + + custom_domain && (community.custom_domain = custom_domain); + default_page && (community.default_page = default_page); + community.snapshot_spaces = snapshots; + name && (community.name = name); + description && (community.description = description); + default_symbol && (community.default_symbol = default_symbol); + icon_url && (community.icon_url = icon_url); + active !== undefined && (community.active = active); + type && (community.type = type); + stages_enabled !== undefined && + (community.stages_enabled = stages_enabled); + has_homepage && (community.has_homepage = has_homepage); + chain_node_id && (community.chain_node_id = chain_node_id); + directory_page_enabled !== undefined && + (community.directory_page_enabled = directory_page_enabled); + directory_page_chain_node_id !== undefined && + (community.directory_page_chain_node_id = directory_page_chain_node_id); + default_summary_view !== undefined && + (community.default_summary_view = default_summary_view); + social_links?.length && (community.social_links = social_links); + hide_projects !== undefined && (community.hide_projects = hide_projects); + custom_stages && (community.custom_stages = custom_stages); + + await community.save(); + + // Suggested solution for serializing BigInts + // https://github.com/GoogleChromeLabs/jsbi/issues/30#issuecomment-1006086291 + (BigInt.prototype as any).toJSON = function () { + return this.toString(); + }; + return (await models.Community.findOne({ where: { id } }))!.toJSON(); + }, + }; +} diff --git a/libs/model/src/community/index.ts b/libs/model/src/community/index.ts index a8ba7cf4347..961d8aed389 100644 --- a/libs/model/src/community/index.ts +++ b/libs/model/src/community/index.ts @@ -10,4 +10,5 @@ export * from './GetStakeHistoricalPrice.query'; export * from './GetStakeTransaction.query'; export * from './RefreshCustomDomain.query'; export * from './SetCommunityStake.command'; +export * from './UpdateCommunity.command'; export * from './UpdateCustomDomain.command'; diff --git a/libs/model/test/community/community-lifecycle.spec.ts b/libs/model/test/community/community-lifecycle.spec.ts index 7f085fa1596..4306908e8c8 100644 --- a/libs/model/test/community/community-lifecycle.spec.ts +++ b/libs/model/test/community/community-lifecycle.spec.ts @@ -5,22 +5,29 @@ import { dispose, query, } from '@hicommonwealth/core'; -import { ChainNodeAttributes } from '@hicommonwealth/model'; import { ChainBase, ChainType } from '@hicommonwealth/shared'; import { Chance } from 'chance'; -import { CreateCommunity, GetCommunities } from 'model/src/community'; -import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { afterAll, assert, beforeAll, describe, expect, test } from 'vitest'; import { + CreateCommunity, CreateGroup, Errors, + GetCommunities, MAX_GROUPS_PER_COMMUNITY, -} from '../../src/community/CreateGroup.command'; + UpdateCommunity, + UpdateCommunityErrors, +} from '../../src/community'; +import type { + ChainNodeAttributes, + CommunityAttributes, +} from '../../src/models'; import { seed } from '../../src/tester'; const chance = Chance(); describe('Community lifecycle', () => { - let node: ChainNodeAttributes; + let ethNode: ChainNodeAttributes, edgewareNode: ChainNodeAttributes; + let community: CommunityAttributes; let actor: Actor; const group_payload = { id: '', @@ -35,10 +42,13 @@ describe('Community lifecycle', () => { }; beforeAll(async () => { - const [_node] = await seed('ChainNode', { eth_chain_id: 1 }); + const [_ethNode] = await seed('ChainNode', { eth_chain_id: 1 }); + const [_edgewareNode] = await seed('ChainNode', { + name: 'Edgeware Mainnet', + }); const [user] = await seed('User', { isAdmin: true }); const [base] = await seed('Community', { - chain_node_id: _node!.id!, + chain_node_id: _ethNode!.id!, base: ChainBase.Ethereum, active: true, lifetime_thread_count: 0, @@ -51,7 +61,8 @@ describe('Community lifecycle', () => { ], }); - node = _node!; + ethNode = _ethNode!; + edgewareNode = _edgewareNode!; actor = { user: { id: user!.id!, email: user!.email!, isAdmin: user!.isAdmin! }, address: base?.Addresses?.at(0)?.address, @@ -73,92 +84,188 @@ describe('Community lifecycle', () => { default_symbol: name.substring(0, 8).replace(' ', ''), network: 'network', base: ChainBase.Ethereum, - eth_chain_id: node.eth_chain_id!, + eth_chain_id: ethNode.eth_chain_id!, social_links: [], user_address: actor.address!, - node_url: node.url, + node_url: ethNode.url, directory_page_enabled: false, tags: [], }, }); expect(result?.community?.id).toBe(name); expect(result?.admin_address).toBe(actor.address); - // connect group payload to new community + // connect results + community = result!.community! as CommunityAttributes; group_payload.id = result!.community!.id; }); - test('should fail to query community via has_groups when none exists', async () => { - const communityResults = await query(GetCommunities(), { - actor, - payload: { has_groups: true } as any, + describe('groups', () => { + test('should fail to query community via has_groups when none exists', async () => { + const communityResults = await query(GetCommunities(), { + actor, + payload: { has_groups: true } as any, + }); + expect(communityResults?.results).to.have.length(0); }); - expect(communityResults?.results).to.have.length(0); - }); - test('should create group when none exists', async () => { - const results = await command(CreateGroup(), { - actor, - payload: group_payload, + test('should create group when none exists', async () => { + const results = await command(CreateGroup(), { + actor, + payload: group_payload, + }); + expect(results?.groups?.at(0)?.metadata).to.includes( + group_payload.metadata, + ); + + const communityResults = await query(GetCommunities(), { + actor, + payload: { has_groups: true } as any, + }); + expect(communityResults?.results?.at(0)?.id).to.equal(group_payload.id); }); - expect(results?.groups?.at(0)?.metadata).to.includes( - group_payload.metadata, - ); - const communityResults = await query(GetCommunities(), { - actor, - payload: { has_groups: true } as any, + test('should fail group creation when group with same id found', async () => { + await expect(() => + command(CreateGroup(), { actor, payload: group_payload }), + ).rejects.toThrow(InvalidState); }); - expect(communityResults?.results?.at(0)?.id).to.equal(group_payload.id); - }); - test('should fail group creation when group with same id found', async () => { - await expect(() => - command(CreateGroup(), { actor, payload: group_payload }), - ).rejects.toThrow(InvalidState); - }); + test('should fail group creation when sending invalid topics', async () => { + await expect( + command(CreateGroup(), { + actor, + payload: { + id: group_payload.id, + metadata: { + name: chance.name(), + description: chance.sentence(), + required_requirements: 1, + }, + requirements: [], + topics: [1, 2, 3], + }, + }), + ).rejects.toThrow(Errors.InvalidTopics); + }); - test('should fail group creation when sending invalid topics', async () => { - await expect( - command(CreateGroup(), { - actor, - payload: { - id: group_payload.id, - metadata: { - name: chance.name(), - description: chance.sentence(), - required_requirements: 1, + test('should fail group creation when community reached max number of groups allowed', async () => { + // create max groups + for (let i = 1; i < MAX_GROUPS_PER_COMMUNITY; i++) { + await command(CreateGroup(), { + actor, + payload: { + id: group_payload.id, + metadata: { name: chance.name(), description: chance.sentence() }, + requirements: [], + topics: [], }, - requirements: [], - topics: [1, 2, 3], - }, - }), - ).rejects.toThrow(Errors.InvalidTopics); + }); + } + + await expect(() => + command(CreateGroup(), { + actor, + payload: { + id: group_payload.id, + metadata: { name: chance.name(), description: chance.sentence() }, + requirements: [], + topics: [], + }, + }), + ).rejects.toThrow(Errors.MaxGroups); + }); }); - test('should fail group creation when community reached max number of groups allowed', async () => { - // create max groups - for (let i = 1; i < MAX_GROUPS_PER_COMMUNITY; i++) { - await command(CreateGroup(), { + describe('updates', () => { + const baseRequest = { + default_symbol: 'EDG', + base: ChainBase.Substrate, + icon_url: 'assets/img/protocols/edg.png', + active: true, + type: ChainType.Chain, + social_links: [], + }; + + test('should update community', async () => { + const updated = await command(UpdateCommunity(), { actor, payload: { - id: group_payload.id, - metadata: { name: chance.name(), description: chance.sentence() }, - requirements: [], - topics: [], + ...baseRequest, + id: community.id, + chain_node_id: ethNode.id, + directory_page_enabled: true, + directory_page_chain_node_id: ethNode.id, + type: ChainType.Offchain, }, }); - } + console.log(updated); - await expect(() => - command(CreateGroup(), { + assert.equal(updated?.directory_page_enabled, true); + assert.equal(updated?.directory_page_chain_node_id, ethNode.id); + assert.equal(updated?.type, 'offchain'); + }); + + test('should remove directory', async () => { + const updated = await command(UpdateCommunity(), { actor, payload: { - id: group_payload.id, - metadata: { name: chance.name(), description: chance.sentence() }, - requirements: [], - topics: [], + ...baseRequest, + id: community.id, + chain_node_id: ethNode.id, + directory_page_enabled: false, + directory_page_chain_node_id: null, + type: ChainType.Chain, }, - }), - ).rejects.toThrow(Errors.MaxGroups); + }); + + assert.equal(updated?.directory_page_enabled, false); + assert.equal(updated?.directory_page_chain_node_id, null); + assert.equal(updated?.type, 'chain'); + }); + + test('should throw if namespace present but no transaction hash', async () => { + await expect(() => + command(UpdateCommunity(), { + actor, + payload: { + ...baseRequest, + id: community.id, + namespace: 'tempNamespace', + chain_node_id: 1263, + }, + }), + ).rejects.toThrow(UpdateCommunityErrors.InvalidTransactionHash); + }); + + test('should throw if actor is not admin', async () => { + await expect(() => + command(UpdateCommunity(), { + actor, + payload: { + ...baseRequest, + id: community.id, + namespace: 'tempNamespace', + transactionHash: '0x1234', + chain_node_id: edgewareNode!.id!, + }, + }), + ).rejects.toThrow(UpdateCommunityErrors.NotAdmin); + }); + + // TODO: implement when we can add members via commands + test.skip('should throw if chain node of community does not match supported chain', async () => { + await expect(() => + command(UpdateCommunity(), { + actor, + payload: { + ...baseRequest, + id: community.id, + namespace: 'tempNamespace', + transactionHash: '0x1234', + chain_node_id: edgewareNode!.id!, + }, + }), + ).rejects.toThrow('Namespace not supported on selected chain'); + }); }); }); diff --git a/libs/schemas/src/commands/community.schemas.ts b/libs/schemas/src/commands/community.schemas.ts index a2190dc3559..a4f731c5486 100644 --- a/libs/schemas/src/commands/community.schemas.ts +++ b/libs/schemas/src/commands/community.schemas.ts @@ -118,12 +118,14 @@ export const UpdateCustomDomain = { }), }; +const Snapshot = z.string().regex(/.+\.(eth|xyz)$/); + export const UpdateCommunity = { - input: z.object({ + input: Community.partial().extend({ id: z.string(), - namespace: z.string(), - txHash: z.string(), - address: z.string(), + featuredTopics: z.array(z.string()).optional(), + snapshot: Snapshot.or(z.array(Snapshot)).optional(), + transactionHash: z.string().optional(), }), output: Community, }; diff --git a/libs/schemas/src/entities/community.schemas.ts b/libs/schemas/src/entities/community.schemas.ts index ce21e850e71..042a65377f3 100644 --- a/libs/schemas/src/entities/community.schemas.ts +++ b/libs/schemas/src/entities/community.schemas.ts @@ -27,7 +27,7 @@ export const Community = z.object({ active: z.boolean(), type: z.nativeEnum(ChainType).default(ChainType.Chain), description: z.string().nullish(), - social_links: z.array(z.string().nullish()).default([]), + social_links: z.array(z.string().url().nullish()).default([]), ss58_prefix: PG_INT.nullish(), stages_enabled: z.boolean().default(true), custom_stages: z.array(z.string()).default([]), @@ -37,7 +37,7 @@ export const Community = z.object({ default_summary_view: z.boolean().nullish(), default_page: z.nativeEnum(DefaultPage).nullish(), has_homepage: z.enum(['true', 'false']).default('false').nullish(), - terms: z.string().nullish(), + terms: z.string().url().nullish(), admin_only_polling: z.boolean().nullish(), bech32_prefix: z.string().nullish(), hide_projects: z.boolean().nullish(), diff --git a/packages/commonwealth/client/scripts/state/api/communities/updateCommunity.ts b/packages/commonwealth/client/scripts/state/api/communities/updateCommunity.ts index e8675fdefd0..7a097f96f80 100644 --- a/packages/commonwealth/client/scripts/state/api/communities/updateCommunity.ts +++ b/packages/commonwealth/client/scripts/state/api/communities/updateCommunity.ts @@ -1,8 +1,6 @@ import { ChainType } from '@hicommonwealth/shared'; -import { useMutation } from '@tanstack/react-query'; -import axios from 'axios'; +import { trpc } from 'client/scripts/utils/trpcClient'; import { initAppState } from 'state'; -import { SERVER_URL } from 'state/api/config'; import { userStore } from '../../ui/user'; import { invalidateAllQueriesForCommunity } from './getCommuityById'; @@ -29,7 +27,7 @@ interface UpdateCommunityProps { isPWA?: boolean; } -const updateCommunity = async ({ +export const buildUpdateCommunityInput = ({ communityId, namespace, symbol, @@ -49,80 +47,45 @@ const updateCommunity = async ({ defaultOverview, chainNodeId, type, - isPWA, }: UpdateCommunityProps) => { - return await axios.patch( - `${SERVER_URL}/communities/${communityId}`, - { - jwt: userStore.getState().jwt, - id: communityId, - ...(namespace && { - namespace, - }), - ...(typeof symbol !== 'undefined' && { - default_symbol: symbol, - }), - ...(typeof transactionHash !== 'undefined' && { - transactionHash, - }), - ...(typeof directoryPageEnabled === 'boolean' && { - directory_page_enabled: directoryPageEnabled, - }), - ...(directoryPageChainNodeId && { - directory_page_chain_node_id: directoryPageChainNodeId, - }), - ...(typeof snapshot !== 'undefined' && { - snapshot, - }), - ...(typeof snapshot !== 'undefined' && { - snapshot, - }), - ...(typeof terms !== 'undefined' && { - terms, - }), - ...(typeof discordBotWebhooksEnabled === 'boolean' && { - discord_bot_webhooks_enabled: discordBotWebhooksEnabled, - }), - ...(typeof name !== 'undefined' && { - name, - }), - ...(typeof name !== 'undefined' && { - name, - }), - ...(typeof description !== 'undefined' && { - description, - }), - ...(typeof socialLinks !== 'undefined' && { - social_links: socialLinks, - }), - ...(typeof stagesEnabled !== 'undefined' && { - stages_enabled: stagesEnabled, - }), - ...(typeof customStages !== 'undefined' && { - custom_stages: customStages, - }), - ...(typeof customDomain !== 'undefined' && { - custom_domain: customDomain, - }), - ...(typeof iconUrl !== 'undefined' && { - icon_url: iconUrl, - }), - ...(typeof defaultOverview !== 'undefined' && { - default_summary_view: defaultOverview, - }), - ...(typeof chainNodeId !== 'undefined' && { - chain_node_id: chainNodeId, - }), - ...(typeof type !== 'undefined' && { - type: type, - }), - }, - { - headers: { - isPWA: isPWA?.toString(), - }, - }, - ); + return { + jwt: userStore.getState().jwt, + id: communityId, + ...(namespace && { namespace }), + ...(typeof symbol !== 'undefined' && { default_symbol: symbol }), + ...(typeof transactionHash !== 'undefined' && { transactionHash }), + ...(typeof directoryPageEnabled === 'boolean' && { + directory_page_enabled: directoryPageEnabled, + }), + ...(directoryPageChainNodeId && { + directory_page_chain_node_id: directoryPageChainNodeId, + }), + ...(typeof snapshot !== 'undefined' && { snapshot }), + ...(typeof snapshot !== 'undefined' && { snapshot }), + ...(typeof terms !== 'undefined' && { terms }), + ...(typeof discordBotWebhooksEnabled === 'boolean' && { + discord_bot_webhooks_enabled: discordBotWebhooksEnabled, + }), + ...(typeof name !== 'undefined' && { name }), + ...(typeof name !== 'undefined' && { name }), + ...(typeof description !== 'undefined' && { description }), + ...(typeof socialLinks !== 'undefined' && { social_links: socialLinks }), + ...(typeof stagesEnabled !== 'undefined' && { + stages_enabled: stagesEnabled, + }), + ...(typeof customStages !== 'undefined' && { + custom_stages: customStages, + }), + ...(typeof customDomain !== 'undefined' && { + custom_domain: customDomain, + }), + ...(typeof iconUrl !== 'undefined' && { icon_url: iconUrl }), + ...(typeof defaultOverview !== 'undefined' && { + default_summary_view: defaultOverview, + }), + ...(typeof chainNodeId !== 'undefined' && { chain_node_id: +chainNodeId }), + ...(typeof type !== 'undefined' && { type: type }), + }; }; type UseUpdateCommunityMutationProps = { @@ -134,8 +97,7 @@ const useUpdateCommunityMutation = ({ communityId, reInitAppOnSuccess, }: UseUpdateCommunityMutationProps) => { - return useMutation({ - mutationFn: updateCommunity, + return trpc.community.updateCommunity.useMutation({ onSuccess: async () => { // since this is the main chain/community object affecting // some other features, better to re-fetch on update. diff --git a/packages/commonwealth/client/scripts/views/pages/AdminPanel/RPCEndpointTask.tsx b/packages/commonwealth/client/scripts/views/pages/AdminPanel/RPCEndpointTask.tsx index f23344d3f38..df62c870ba3 100644 --- a/packages/commonwealth/client/scripts/views/pages/AdminPanel/RPCEndpointTask.tsx +++ b/packages/commonwealth/client/scripts/views/pages/AdminPanel/RPCEndpointTask.tsx @@ -1,5 +1,6 @@ import { BalanceType, ChainType } from '@hicommonwealth/shared'; import axios from 'axios'; +import { buildUpdateCommunityInput } from 'client/scripts/state/api/communities/updateCommunity'; import { notifyError, notifySuccess } from 'controllers/app/notifications'; import { detectURL } from 'helpers/threads'; import NodeInfo from 'models/NodeInfo'; @@ -197,11 +198,13 @@ const RPCEndpointTask = () => { Object.keys(communityLookupData || {}).length > 0 && communityInfoValueValidated ) { - await updateCommunity({ - communityId: communityLookupData?.id, - chainNodeId: nodeId ?? communityChainNode?.id?.toString(), - type: ChainType.Chain, - }); + await updateCommunity( + buildUpdateCommunityInput({ + communityId: communityLookupData?.id, + chainNodeId: nodeId ?? communityChainNode?.id?.toString(), + type: ChainType.Chain, + }), + ); } setRpcEndpointCommunityId(''); diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/CommunityProfile/CommunityProfileForm/CommunityProfileForm.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/CommunityProfile/CommunityProfileForm/CommunityProfileForm.tsx index 8d11821a4ee..6e5f0a9a20e 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/CommunityProfile/CommunityProfileForm/CommunityProfileForm.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/CommunityProfile/CommunityProfileForm/CommunityProfileForm.tsx @@ -1,4 +1,5 @@ import { DefaultPage } from '@hicommonwealth/shared'; +import { buildUpdateCommunityInput } from 'client/scripts/state/api/communities/updateCommunity'; import { notifyError, notifySuccess } from 'controllers/app/notifications'; import { linkValidationSchema } from 'helpers/formValidations/common'; import { getLinkType, isLinkValid } from 'helpers/link'; @@ -150,18 +151,20 @@ const CommunityProfileForm = () => { bannerText: values.communityBanner ?? '', }); - await updateCommunity({ - communityId: community.id, - name: values.communityName, - description: values.communityDescription, - socialLinks: links.map((link) => link.value.trim()), - stagesEnabled: values.hasStagesEnabled, - customStages: values.customStages - ? JSON.parse(values.customStages) - : [], - iconUrl: values.communityProfileImageURL, - defaultOverview: values.defaultPage === DefaultPage.Overview, - }); + await updateCommunity( + buildUpdateCommunityInput({ + communityId: community.id, + name: values.communityName, + description: values.communityDescription, + socialLinks: links.map((link) => link.value.trim()), + stagesEnabled: values.hasStagesEnabled, + customStages: values.customStages + ? JSON.parse(values.customStages) + : [], + iconUrl: values.communityProfileImageURL, + defaultOverview: values.defaultPage === DefaultPage.Overview, + }), + ); setNameFieldDisabledState({ isDisabled: true, diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/CustomTOS/CustomTOS.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/CustomTOS/CustomTOS.tsx index 72158a3a3e0..f90f4482527 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/CustomTOS/CustomTOS.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/CustomTOS/CustomTOS.tsx @@ -1,3 +1,4 @@ +import { buildUpdateCommunityInput } from 'client/scripts/state/api/communities/updateCommunity'; import { notifySuccess } from 'controllers/app/notifications'; import { linkValidationSchema } from 'helpers/formValidations/common'; import useRunOnceOnCondition from 'hooks/useRunOnceOnCondition'; @@ -61,10 +62,12 @@ const CustomTOS = () => { setIsSaving(true); try { - await updateCommunity({ - communityId: community?.id, - terms: terms.value || '', - }); + await updateCommunity( + buildUpdateCommunityInput({ + communityId: community?.id, + terms: terms.value || '', + }), + ); notifySuccess('TOS link updated!'); } catch { diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Directory/Directory.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Directory/Directory.tsx index 8713fa3c38a..c01034f14be 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Directory/Directory.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Directory/Directory.tsx @@ -1,3 +1,4 @@ +import { buildUpdateCommunityInput } from 'client/scripts/state/api/communities/updateCommunity'; import { notifyError, notifySuccess } from 'controllers/app/notifications'; import useAppStatus from 'hooks/useAppStatus'; import useRunOnceOnCondition from 'hooks/useRunOnceOnCondition'; @@ -71,12 +72,14 @@ const Directory = () => { try { setIsSaving(true); - await updateCommunity({ - communityId: community?.id, - directoryPageChainNodeId: chainNodeId || undefined, - directoryPageEnabled: isEnabled, - isPWA: isAddedToHomeScreen, - }); + await updateCommunity( + buildUpdateCommunityInput({ + communityId: community?.id, + directoryPageChainNodeId: chainNodeId || undefined, + directoryPageEnabled: isEnabled, + isPWA: isAddedToHomeScreen, + }), + ); notifySuccess('Updated community directory'); app.sidebarRedraw.emit('redraw'); diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Discord/Discord.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Discord/Discord.tsx index d6d5efa83f3..352cba9e595 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Discord/Discord.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Discord/Discord.tsx @@ -1,3 +1,4 @@ +import { buildUpdateCommunityInput } from 'client/scripts/state/api/communities/updateCommunity'; import { notifyError, notifySuccess } from 'controllers/app/notifications'; import { uuidv4 } from 'lib/util'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; @@ -130,10 +131,12 @@ const Discord = () => { const toggleMsgType = isDiscordWebhooksEnabled ? 'disable' : 'enable'; try { - await updateCommunity({ - communityId: community?.id, - discordBotWebhooksEnabled: !isDiscordWebhooksEnabled, - }); + await updateCommunity( + buildUpdateCommunityInput({ + communityId: community?.id, + discordBotWebhooksEnabled: !isDiscordWebhooksEnabled, + }), + ); setIsDiscordWebhooksEnabled(!isDiscordWebhooksEnabled); notifySuccess(`Discord webhooks ${toggleMsgType}d!`); diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Snapshots/Snapshots.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Snapshots/Snapshots.tsx index de01c4cc966..26dee8f132d 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Snapshots/Snapshots.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityManagement/Integrations/Snapshots/Snapshots.tsx @@ -13,6 +13,7 @@ import { CWButton } from 'views/components/component_kit/new_designs/CWButton'; import './Snapshots.scss'; import { snapshotValidationSchema } from './validation'; +import { buildUpdateCommunityInput } from 'client/scripts/state/api/communities/updateCommunity'; const Snapshots = () => { const { data: community, isLoading: isLoadingCommunity } = useGetCommunityByIdQuery({ @@ -68,10 +69,12 @@ const Snapshots = () => { ), ]; - await updateCommunity({ - communityId: community?.id, - snapshot: newSnapshots, - }); + await updateCommunity( + buildUpdateCommunityInput({ + communityId: community?.id, + snapshot: newSnapshots, + }), + ); setLinks( newSnapshots.map((snapshot) => ({ diff --git a/packages/commonwealth/client/scripts/views/pages/CreateCommunity/steps/CommunityStakeStep/SignStakeTransactions/useReserveCommunityNamespace.tsx b/packages/commonwealth/client/scripts/views/pages/CreateCommunity/steps/CommunityStakeStep/SignStakeTransactions/useReserveCommunityNamespace.tsx index 1fe9846aaaa..9f2749b027d 100644 --- a/packages/commonwealth/client/scripts/views/pages/CreateCommunity/steps/CommunityStakeStep/SignStakeTransactions/useReserveCommunityNamespace.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CreateCommunity/steps/CommunityStakeStep/SignStakeTransactions/useReserveCommunityNamespace.tsx @@ -1,3 +1,4 @@ +import { buildUpdateCommunityInput } from 'client/scripts/state/api/communities/updateCommunity'; import { useBrowserAnalyticsTrack } from 'hooks/useBrowserAnalyticsTrack'; import { useState } from 'react'; import { @@ -55,12 +56,14 @@ const useReserveCommunityNamespace = ({ chainId, ); - await updateCommunity({ - communityId, - namespace, - symbol, - transactionHash: txReceipt.transactionHash, - }); + await updateCommunity( + buildUpdateCommunityInput({ + communityId, + namespace, + symbol, + transactionHash: txReceipt.transactionHash, + }), + ); setReserveNamespaceData({ state: 'completed', diff --git a/packages/commonwealth/server/api/community.ts b/packages/commonwealth/server/api/community.ts index cc18307d604..7b5dfe5af4f 100644 --- a/packages/commonwealth/server/api/community.ts +++ b/packages/commonwealth/server/api/community.ts @@ -5,6 +5,31 @@ import { MixpanelCommunityInteractionEvent, } from '../../shared/analytics/types'; +// TODO: mixpanel events for update community +/* +let mixpanelEvent: MixpanelCommunityInteractionEvent; +let communitySelected = null; + +if (community.directory_page_enabled !== directory_page_enabled) { + mixpanelEvent = directory_page_enabled + ? MixpanelCommunityInteractionEvent.DIRECTORY_PAGE_ENABLED + : MixpanelCommunityInteractionEvent.DIRECTORY_PAGE_DISABLED; + + if (directory_page_enabled) { + communitySelected = await this.models.Community.findOne({ + where: { chain_node_id: directory_page_chain_node_id! }, + }); + } +} +const analyticsOptions = { + event: mixpanelEvent, + community: community.id, + userId: user.id, + isCustomDomain: null, + ...(communitySelected && { communitySelected: communitySelected.id }), +}; +*/ + export const trpcRouter = trpc.router({ createCommunity: trpc.command(Community.CreateCommunity, trpc.Tag.Community, [ MixpanelCommunityCreationEvent.NEW_COMMUNITY_CREATION, @@ -13,6 +38,7 @@ export const trpcRouter = trpc.router({ community: result.community?.id, }), ]), + updateCommunity: trpc.command(Community.UpdateCommunity, trpc.Tag.Community), getCommunities: trpc.query(Community.GetCommunities, trpc.Tag.Community), getCommunity: trpc.query(Community.GetCommunity, trpc.Tag.Community), getStake: trpc.query(Community.GetCommunityStake, trpc.Tag.Community), @@ -41,5 +67,4 @@ export const trpcRouter = trpc.router({ Community.UpdateCustomDomain, trpc.Tag.Community, ), - // TODO: integrate via async analytics policy: analyticsMiddleware(MixpanelCommunityInteractionEvent.CREATE_GROUP), }); diff --git a/packages/commonwealth/server/api/external-router.ts b/packages/commonwealth/server/api/external-router.ts index 61ef3333b2d..5d5e561e658 100644 --- a/packages/commonwealth/server/api/external-router.ts +++ b/packages/commonwealth/server/api/external-router.ts @@ -7,8 +7,13 @@ import * as comment from './comment'; import * as community from './community'; import * as thread from './threads'; -const { createCommunity, getCommunities, getCommunity, getMembers } = - community.trpcRouter; +const { + createCommunity, + updateCommunity, + getCommunities, + getCommunity, + getMembers, +} = community.trpcRouter; const { createThread, createThreadReaction } = thread.trpcRouter; const { createComment, createCommentReaction, updateComment, getComments } = comment.trpcRouter; @@ -16,6 +21,7 @@ const { createComment, createCommentReaction, updateComment, getComments } = const api = { createCommunity, + updateCommunity, getCommunities, getCommunity, getMembers, diff --git a/packages/commonwealth/server/controllers/server_communities_controller.ts b/packages/commonwealth/server/controllers/server_communities_controller.ts index 42dcb11c119..912a45fa3d7 100644 --- a/packages/commonwealth/server/controllers/server_communities_controller.ts +++ b/packages/commonwealth/server/controllers/server_communities_controller.ts @@ -43,11 +43,6 @@ import { UpdateChainNodeResult, __updateChainNode, } from './server_communities_methods/update_chain_node'; -import { - UpdateCommunityOptions, - UpdateCommunityResult, - __updateCommunity, -} from './server_communities_methods/update_community'; import { UpdateCommunityIdOptions, UpdateCommunityIdResult, @@ -72,12 +67,6 @@ export class ServerCommunitiesController { return __getCommunities.call(this, options); } - async updateCommunity( - options: UpdateCommunityOptions, - ): Promise { - return __updateCommunity.call(this, options); - } - async deleteCommunity( options: DeleteCommunityOptions, ): Promise { diff --git a/packages/commonwealth/server/controllers/server_communities_methods/update_community.ts b/packages/commonwealth/server/controllers/server_communities_methods/update_community.ts deleted file mode 100644 index e54e717dbd7..00000000000 --- a/packages/commonwealth/server/controllers/server_communities_methods/update_community.ts +++ /dev/null @@ -1,269 +0,0 @@ -/* eslint-disable no-continue */ -import { AppError } from '@hicommonwealth/core'; -import { - checkSnapshotObjectExists, - commonProtocol, - CommunityAttributes, - UserInstance, -} from '@hicommonwealth/model'; -import { ChainBase } from '@hicommonwealth/shared'; -import { MixpanelCommunityInteractionEvent } from '../../../shared/analytics/types'; -import { urlHasValidHTTPPrefix } from '../../../shared/utils'; -import { ALL_COMMUNITIES } from '../../middleware/databaseValidationService'; -import { TrackOptions } from '../server_analytics_controller'; -import { ServerCommunitiesController } from '../server_communities_controller'; - -export const Errors = { - NotLoggedIn: 'Not signed in', - NoCommunityId: 'Must provide community ID', - ReservedId: 'The id is reserved and cannot be used', - CantChangeCustomDomain: 'Custom domain change not permitted', - CustomDomainIsTaken: 'Custom domain is taken by another community', - CantChangeNetwork: 'Cannot change community network', - NotAdmin: 'Not an admin', - NoCommunityFound: 'Community not found', - InvalidSocialLink: 'Social Link must begin with http(s)://', - InvalidCustomDomain: 'Custom domain may not include "commonwealth"', - InvalidSnapshot: 'Snapshot must fit the naming pattern of *.eth or *.xyz', - SnapshotOnlyOnEthereum: - 'Snapshot data may only be added to chains with Ethereum base', - InvalidTerms: 'Terms of Service must begin with https://', - InvalidDefaultPage: 'Default page does not exist', - InvalidTransactionHash: 'Valid transaction hash required to verify namespace', -}; - -export type UpdateCommunityOptions = CommunityAttributes & { - user: UserInstance; - featuredTopics?: string[]; - snapshot?: string[]; - transactionHash?: string; -}; -export type UpdateCommunityResult = CommunityAttributes & { - snapshot: string[]; - analyticsOptions: TrackOptions; -}; - -export async function __updateCommunity( - this: ServerCommunitiesController, - { user, id, network, ...rest }: UpdateCommunityOptions, -): Promise { - if (!user) { - throw new AppError(Errors.NotLoggedIn); - } - if (!id) { - throw new AppError(Errors.NoCommunityId); - } - if (id === ALL_COMMUNITIES) { - throw new AppError(Errors.ReservedId); - } - if (network) { - throw new AppError(Errors.CantChangeNetwork); - } - - const community = await this.models.Community.findOne({ - where: { id }, - include: [ - { - model: this.models.ChainNode, - attributes: ['url', 'eth_chain_id', 'cosmos_chain_id'], - }, - ], - }); - - if (!community) { - throw new AppError(Errors.NoCommunityFound); - } - - const communityAdmins = await user.getAddresses({ - where: { - community_id: community.id, - role: 'admin', - }, - }); - - if (!user.isAdmin && communityAdmins.length === 0) { - throw new AppError(Errors.NotAdmin); - } - - // TODO: what do we do to select the proper admin to deploy namespace further down? - const communityAdmin = communityAdmins[0]; - - const { - active, - icon_url, - default_symbol, - type, - name, - description, - social_links, - hide_projects, - stages_enabled, - custom_stages, - custom_domain, - default_summary_view, - default_page, - has_homepage, - terms, - chain_node_id, - directory_page_enabled, - directory_page_chain_node_id, - namespace, - transactionHash, - } = rest; - - // Handle single string case and undefined case - let { snapshot } = rest; - if (snapshot !== undefined && typeof snapshot === 'string') { - snapshot = [snapshot]; - } else if (snapshot === undefined) { - snapshot = []; - } - - const nonEmptySocialLinks = (social_links || [])?.filter( - (s) => typeof s === 'string', - ); - const invalidSocialLinks = nonEmptySocialLinks?.filter( - (s) => !urlHasValidHTTPPrefix(s || ''), - ); - if (nonEmptySocialLinks && invalidSocialLinks.length > 0) { - throw new AppError(`${invalidSocialLinks[0]}: ${Errors.InvalidSocialLink}`); - } else if (custom_domain && custom_domain.includes('commonwealth')) { - throw new AppError(Errors.InvalidCustomDomain); - } else if ( - snapshot.some((snapshot_space) => { - const lastFour = snapshot_space.slice(snapshot_space.length - 4); - return ( - snapshot_space !== '' && lastFour !== '.eth' && lastFour !== '.xyz' - ); - }) - ) { - throw new AppError(Errors.InvalidSnapshot); - } else if (snapshot.length > 0 && community.base !== ChainBase.Ethereum) { - throw new AppError(Errors.SnapshotOnlyOnEthereum); - } else if (terms && !urlHasValidHTTPPrefix(terms)) { - throw new AppError(Errors.InvalidTerms); - } - - const newSpaces = snapshot.filter((space) => { - return !community.snapshot_spaces.includes(space); - }); - for (const space of newSpaces) { - if (!(await checkSnapshotObjectExists('space', space))) { - throw new AppError(Errors.InvalidSnapshot); - } - } - - community.snapshot_spaces = snapshot; - - if (name) community.name = name; - if (description) community.description = description; - if (default_symbol) community.default_symbol = default_symbol; - if (icon_url) community.icon_url = icon_url; - if (active !== undefined) community.active = active; - if (type) community.type = type; - if (nonEmptySocialLinks !== undefined && nonEmptySocialLinks.length >= 0) - community.social_links = nonEmptySocialLinks; - if (hide_projects) community.hide_projects = hide_projects; - if (typeof stages_enabled === 'boolean') - community.stages_enabled = stages_enabled; - if (Array.isArray(custom_stages)) { - community.custom_stages = custom_stages; - } - if (typeof terms === 'string') community.terms = terms; - if (has_homepage === 'true') community.has_homepage = has_homepage; - if (default_page) { - if (has_homepage !== 'true') { - throw new AppError(Errors.InvalidDefaultPage); - } else { - community.default_page = default_page; - } - } - if (chain_node_id) { - community.chain_node_id = chain_node_id; - } - - let mixpanelEvent: MixpanelCommunityInteractionEvent; - let communitySelected = null; - - if (community.directory_page_enabled !== directory_page_enabled) { - mixpanelEvent = directory_page_enabled - ? MixpanelCommunityInteractionEvent.DIRECTORY_PAGE_ENABLED - : MixpanelCommunityInteractionEvent.DIRECTORY_PAGE_DISABLED; - - if (directory_page_enabled) { - // @ts-expect-error StrictNullChecks - communitySelected = await this.models.Community.findOne({ - where: { chain_node_id: directory_page_chain_node_id! }, - }); - } - } - - if (directory_page_enabled !== undefined) { - community.directory_page_enabled = directory_page_enabled; - } - if (directory_page_chain_node_id !== undefined) { - community.directory_page_chain_node_id = directory_page_chain_node_id; - } - if (namespace !== undefined) { - if (!transactionHash) { - throw new AppError(Errors.InvalidTransactionHash); - } - - // we only permit the community admin and not the site admin to create namespace - if (!communityAdmin) { - throw new AppError(Errors.NotAdmin); - } - - const namespaceAddress = - await commonProtocol.newNamespaceValidator.validateNamespace( - namespace!, - transactionHash, - communityAdmin.address, - community, - ); - - community.namespace = namespace; - community.namespace_address = namespaceAddress; - } - - // TODO Graham 3/31/22: Will this potentially lead to undesirable effects if toggle - // is left un-updated? Is there a better approach? - community.default_summary_view = default_summary_view || false; - - // Only permit site admins to update custom domain field on communities, as it requires - // external configuration (via heroku + whitelists). - // Currently does not permit unsetting the custom domain; must be done manually. - if (user.isAdmin && custom_domain) { - // verify if this custom domain is taken by another community - const foundCommunity = await this.models.Community.findOne({ - where: { custom_domain: custom_domain! }, - }); - if (foundCommunity) { - throw new AppError(Errors.CustomDomainIsTaken); - } - - community.custom_domain = custom_domain; - } else if (custom_domain && custom_domain !== community.custom_domain) { - throw new AppError(Errors.CantChangeCustomDomain); - } - - await community.save(); - - // Suggested solution for serializing BigInts - // https://github.com/GoogleChromeLabs/jsbi/issues/30#issuecomment-1006086291 - (BigInt.prototype as any).toJSON = function () { - return this.toString(); - }; - - const analyticsOptions = { - // @ts-expect-error StrictNullChecks - event: mixpanelEvent, - community: community.id, - userId: user.id, - isCustomDomain: null, - // @ts-expect-error StrictNullChecks - ...(communitySelected && { communitySelected: communitySelected.id }), - }; - - return { ...community.toJSON(), snapshot, analyticsOptions }; -} diff --git a/packages/commonwealth/server/routes/communities/update_community_handler.ts b/packages/commonwealth/server/routes/communities/update_community_handler.ts deleted file mode 100644 index 1c4285a6d4f..00000000000 --- a/packages/commonwealth/server/routes/communities/update_community_handler.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { CommunityAttributes } from '@hicommonwealth/model'; -import { UpdateCommunityResult } from 'server/controllers/server_communities_methods/update_community'; -import { ServerControllers } from '../../routing/router'; -import { TypedRequestBody, TypedResponse, success } from '../../types'; - -type UpdateCommunityRequestBody = CommunityAttributes & { - id: string; - 'featured_topics[]'?: string[]; - 'snapshot[]'?: string[]; - transactionHash?: string; // necessary for namespace update -}; -type UpdateCommunityResponse = UpdateCommunityResult; - -export const updateCommunityHandler = async ( - controllers: ServerControllers, - req: TypedRequestBody, - res: TypedResponse, -) => { - const { - 'featured_topics[]': featuredTopics, - 'snapshot[]': snapshot, - ...rest - } = req.body; - const { analyticsOptions, ...community } = - await controllers.communities.updateCommunity({ - // @ts-expect-error StrictNullChecks - user: req.user, - featuredTopics, - snapshot, - ...rest, - }); - - controllers.analytics.track(analyticsOptions, req).catch(console.error); - - return success(res, community); -}; diff --git a/packages/commonwealth/server/routing/router.ts b/packages/commonwealth/server/routing/router.ts index ca410e7bcc7..650fdd515ed 100644 --- a/packages/commonwealth/server/routing/router.ts +++ b/packages/commonwealth/server/routing/router.ts @@ -99,7 +99,6 @@ import { createChainNodeHandler } from '../routes/communities/create_chain_node_ import { deleteCommunityHandler } from '../routes/communities/delete_community_handler'; import { getChainNodesHandler } from '../routes/communities/get_chain_nodes_handler'; import { getCommunitiesHandler } from '../routes/communities/get_communities_handler'; -import { updateCommunityHandler } from '../routes/communities/update_community_handler'; import { updateCommunityIdHandler } from '../routes/communities/update_community_id_handler'; import exportMembersList from '../routes/exportMembersList'; import { getFeedHandler } from '../routes/feed'; @@ -265,13 +264,6 @@ function setupRouter( updateCommunityIdHandler.bind(this, models, serverControllers), ); - registerRoute( - router, - 'patch', - '/communities/:communityId', - passport.authenticate('jwt', { session: false }), - updateCommunityHandler.bind(this, serverControllers), - ); registerRoute( router, 'get', diff --git a/packages/commonwealth/test/integration/api/updateChain.spec.ts b/packages/commonwealth/test/integration/api/updateChain.spec.ts deleted file mode 100644 index 61a3f686f2a..00000000000 --- a/packages/commonwealth/test/integration/api/updateChain.spec.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { dispose } from '@hicommonwealth/core'; -import { - tester, - tokenBalanceCache, - type CommunityAttributes, - type DB, - type UserInstance, -} from '@hicommonwealth/model'; -import { ChainBase, ChainType } from '@hicommonwealth/shared'; -import { assert } from 'chai'; -import Sinon from 'sinon'; -import { afterAll, beforeAll, describe, test } from 'vitest'; -import { ServerCommunitiesController } from '../../../server/controllers/server_communities_controller'; -import { Errors } from '../../../server/controllers/server_communities_methods/update_community'; -import { buildUser } from '../../unit/unitHelpers'; - -const baseRequest: CommunityAttributes = { - id: 'ethereum', - name: 'ethereum', - chain_node_id: 1, - default_symbol: 'EDG', - // @ts-expect-error StrictNullChecks - network: null, - base: ChainBase.Substrate, - icon_url: 'assets/img/protocols/edg.png', - active: true, - type: ChainType.Chain, - social_links: [], -}; - -describe('UpdateChain Tests', () => { - let models: DB; - - beforeAll(async () => { - models = await tester.seedDb(); - }); - - afterAll(async () => { - await dispose()(); - }); - - test('Correctly updates chain', async () => { - // @ts-expect-error StrictNullChecks - const controller = new ServerCommunitiesController(models, null); - const user: UserInstance = buildUser({ - models, - userAttributes: { email: '', id: 1, isAdmin: true, profile: {} }, - }) as UserInstance; - - let response = await controller.updateCommunity({ - ...baseRequest, - directory_page_enabled: true, - directory_page_chain_node_id: 1, - type: ChainType.Offchain, - user: user, - }); - - assert.equal(response.directory_page_enabled, true); - assert.equal(response.directory_page_chain_node_id, 1); - assert.equal(response.type, 'offchain'); - - response = await controller.updateCommunity({ - ...baseRequest, - directory_page_enabled: false, - directory_page_chain_node_id: null, - type: ChainType.Chain, - user: user, - }); - - assert.equal(response.directory_page_enabled, false); - assert.equal(response.directory_page_chain_node_id, null); - assert.equal(response.type, 'chain'); - }); - - test('Fails if namespace present but no transaction hash', async () => { - // @ts-expect-error StrictNullChecks - const controller = new ServerCommunitiesController(models, null); - const user: UserInstance = buildUser({ - models, - userAttributes: { email: '', id: 2, isAdmin: false, profile: {} }, - }) as UserInstance; - - try { - await controller.updateCommunity({ - ...baseRequest, - user: user, - namespace: 'tempNamespace', - chain_node_id: 1263, - }); - } catch (e) { - assert.equal(e.message, Errors.InvalidTransactionHash); - } - }); - - test('Fails if chain node of community does not match supported chain', async () => { - // @ts-expect-error StrictNullChecks - const controller = new ServerCommunitiesController(models, null); - const user: UserInstance = buildUser({ - models, - userAttributes: { email: '', id: 2, isAdmin: false, profile: {} }, - }) as UserInstance; - - const incorrectChainNode = await models.ChainNode.findOne({ - where: { - name: 'Edgeware Mainnet', - }, - }); - - try { - await controller.updateCommunity({ - ...baseRequest, - user: user, - namespace: 'tempNamespace', - transactionHash: '0x1234', - chain_node_id: incorrectChainNode!.id!, - }); - } catch (e) { - assert.equal(e.message, 'Namespace not supported on selected chain'); - } - }); - - // skipped because public chainNodes are unreliable. If you want to test this functionality, update the goleri - // chainNode and do it locally. - test.skip('Correctly updates namespace', async () => { - Sinon.stub(tokenBalanceCache, 'getBalances').resolves({ - '0x42D6716549A78c05FD8EF1f999D52751Bbf9F46a': '1', - }); - - // @ts-expect-error StrictNullChecks - const controller = new ServerCommunitiesController(models, null); - const user: UserInstance = buildUser({ - models, - userAttributes: { email: '', id: 2, isAdmin: false, profile: {} }, - }) as UserInstance; - - // change chain node to one that supports namespace - await controller.updateCommunity({ - ...baseRequest, - user: user, - chain_node_id: 1263, - }); - - const response = await controller.updateCommunity({ - ...baseRequest, - user: user, - namespace: 'IanSpace', - transactionHash: - '0x474369b51a06b06327b292f25679dcc8765113e002689616e6ab02fa6332690b', - }); - - assert.equal(response.namespace, 'IanSpace'); - Sinon.restore(); - }); -}); diff --git a/packages/commonwealth/test/integration/api/updatecommunity.spec.ts b/packages/commonwealth/test/integration/api/updatecommunity.spec.ts index 9b3665a45e5..6cb343a95c9 100644 --- a/packages/commonwealth/test/integration/api/updatecommunity.spec.ts +++ b/packages/commonwealth/test/integration/api/updatecommunity.spec.ts @@ -2,16 +2,30 @@ import { dispose } from '@hicommonwealth/core'; import chai from 'chai'; import chaiHttp from 'chai-http'; +import { Express } from 'express'; import jwt from 'jsonwebtoken'; +import { UpdateCommunityErrors } from 'node_modules/@hicommonwealth/model/src/community'; import { CommunityArgs } from 'test/util/modelUtils'; import { afterAll, beforeAll, describe, test } from 'vitest'; import { TestServer, testServer } from '../../../server-test'; import { config } from '../../../server/config'; -import { Errors as ChainError } from '../../../server/controllers/server_communities_methods/update_community'; chai.use(chaiHttp); const { expect } = chai; +async function update( + app: Express, + address: string, + payload: Record, +) { + return await chai + .request(app) + .post(`/api/v1/UpdateCommunity`) + .set('Accept', 'application/json') + .set('address', address) + .send(payload); +} + describe('Update Community/Chain Tests', () => { let jwtToken; let siteAdminJwt; @@ -28,7 +42,7 @@ describe('Update Community/Chain Tests', () => { { chain }, 'Alice', ); - loggedInAddr = result.address; + loggedInAddr = result.address.split(':')[2]; jwtToken = jwt.sign( { id: result.user_id, email: result.email }, config.AUTH.JWT_SECRET, @@ -67,7 +81,8 @@ describe('Update Community/Chain Tests', () => { default_chain: chain, }; - await server.seeder.createCommunity(communityArgs); + const created = await server.seeder.createCommunity(communityArgs); + expect(created.name).to.be.equal(communityArgs.name); }); afterAll(async () => { @@ -77,177 +92,172 @@ describe('Update Community/Chain Tests', () => { describe('/updateChain route tests', () => { test('should update chain name', async () => { const name = 'commonwealtheum'; - const res = await chai - .request(server.app) - .patch(`/api/communities/${chain}`) - .set('Accept', 'application/json') - .send({ jwt: jwtToken, id: chain, name }); - expect(res.body.status).to.be.equal('Success'); - expect(res.body.result.name).to.be.equal(name); + const res = await update(server.app, loggedInAddr, { + jwt: jwtToken, + id: chain, + name, + }); + expect(res.status).to.be.equal(200); + expect(res.body.name).to.be.equal(name); }); test('should update description', async () => { const description = 'hello this the new chain'; - const res = await chai - .request(server.app) - .patch(`/api/communities/${chain}`) - .set('Accept', 'application/json') - .send({ jwt: jwtToken, id: chain, description }); - expect(res.body.status).to.be.equal('Success'); - expect(res.body.result.description).to.be.equal(description); + const res = await update(server.app, loggedInAddr, { + jwt: jwtToken, + id: chain, + description, + }); + expect(res.status).to.be.equal(200); + expect(res.body.description).to.be.equal(description); }); test.skip('should update website', async () => { const website = 'http://edgewa.re'; - const res = await chai - .request(server.app) - .patch(`/api/communities/${chain}`) - .set('Accept', 'application/json') - .send({ jwt: jwtToken, id: chain, website }); - expect(res.body.status).to.be.equal('Success'); - expect(res.body.result.website).to.be.equal(website); + const res = await update(server.app, loggedInAddr, { + jwt: jwtToken, + id: chain, + website, + }); + expect(res.status).to.be.equal(200); + expect(res.body.website).to.be.equal(website); }); test('should update discord', async () => { const discord = ['http://discord.gg']; - const res = await chai - .request(server.app) - .patch(`/api/communities/${chain}`) - .set('Accept', 'application/json') - .send({ jwt: jwtToken, id: chain, social_links: discord }); - expect(res.body.status).to.be.equal('Success'); - expect(res.body.result.social_links).to.deep.equal(discord); + const res = await update(server.app, loggedInAddr, { + jwt: jwtToken, + id: chain, + social_links: discord, + }); + expect(res.status).to.be.equal(200); + expect(res.body.social_links).to.deep.equal(discord); }); test.skip('should fail to update social link without proper prefix', async () => { const socialLinks = ['github.com']; - const res = await chai - .request(server.app) - .patch(`/api/communities/${chain}`) - .set('Accept', 'application/json') - .send({ jwt: jwtToken, id: chain, socialLinks }); + const res = await update(server.app, loggedInAddr, { + jwt: jwtToken, + id: chain, + socialLinks, + }); expect(res.body.error).to.exist; }); test('should update telegram', async () => { const telegram = ['https://t.me/']; - const res = await chai - .request(server.app) - .patch(`/api/communities/${chain}`) - .set('Accept', 'application/json') - .send({ jwt: jwtToken, id: chain, social_links: telegram }); - expect(res.body.status).to.be.equal('Success'); - expect(res.body.result.social_links).to.deep.equal(telegram); + const res = await update(server.app, loggedInAddr, { + jwt: jwtToken, + id: chain, + social_links: telegram, + }); + expect(res.status).to.be.equal(200); + expect(res.body.social_links).to.deep.equal(telegram); }); test.skip('should update github', async () => { const github = ['https://github.com/']; - const res = await chai - .request(server.app) - .patch(`/api/communities/${chain}`) - .set('Accept', 'application/json') - .send({ jwt: jwtToken, id: chain, github }); - expect(res.body.status).to.be.equal('Success'); - expect(res.body.result.github).to.deep.equal(github); + const res = await update(server.app, loggedInAddr, { + jwt: jwtToken, + id: chain, + github, + }); + expect(res.status).to.be.equal(200); + expect(res.body.github).to.deep.equal(github); }); test('should update symbol', async () => { const default_symbol = 'CWL'; - const res = await chai - .request(server.app) - .patch(`/api/communities/${chain}`) - .set('Accept', 'application/json') - .send({ jwt: jwtToken, id: chain, default_symbol }); - expect(res.body.status).to.be.equal('Success'); - expect(res.body.result.default_symbol).to.be.equal(default_symbol); + const res = await update(server.app, loggedInAddr, { + jwt: jwtToken, + id: chain, + default_symbol, + }); + expect(res.status).to.be.equal(200); + expect(res.body.default_symbol).to.be.equal(default_symbol); }); test('should update icon_url', async () => { const icon_url = 'assets/img/protocols/cwl.png'; - const res = await chai - .request(server.app) - .patch(`/api/communities/${chain}`) - .set('Accept', 'application/json') - .send({ jwt: jwtToken, id: chain, icon_url }); - expect(res.body.status).to.be.equal('Success'); - expect(res.body.result.icon_url).to.be.equal(icon_url); + const res = await update(server.app, loggedInAddr, { + jwt: jwtToken, + id: chain, + icon_url, + }); + expect(res.status).to.be.equal(200); + expect(res.body.icon_url).to.be.equal(icon_url); }); test('should update active', async () => { const active = false; - const res = await chai - .request(server.app) - .patch(`/api/communities/${chain}`) - .set('Accept', 'application/json') - .send({ jwt: jwtToken, id: chain, active }); - expect(res.body.status).to.be.equal('Success'); - expect(res.body.result.active).to.be.equal(active); + const res = await update(server.app, loggedInAddr, { + jwt: jwtToken, + id: chain, + active, + }); + expect(res.status).to.be.equal(200); + expect(res.body.active).to.be.equal(active); }); - test('should update type', async () => { + test.skip('should update type', async () => { const type = 'parachain'; - const res = await chai - .request(server.app) - .patch(`/api/communities/${chain}`) - .set('Accept', 'application/json') - .send({ jwt: jwtToken, id: chain, type }); - expect(res.body.status).to.be.equal('Success'); - expect(res.body.result.type).to.be.equal(type); + const res = await update(server.app, loggedInAddr, { + jwt: jwtToken, + id: chain, + type, + }); + expect(res.status).to.be.equal(200); + expect(res.body.type).to.be.equal(type); }); test('should fail to update network', async () => { const network = 'ethereum-testnet'; - const res = await chai - .request(server.app) - .patch(`/api/communities/${chain}`) - .set('Accept', 'application/json') - .send({ jwt: jwtToken, id: chain, network }); - expect(res.body.error).to.not.be.null; - expect(res.body.error).to.be.equal(ChainError.CantChangeNetwork); + const res = await update(server.app, loggedInAddr, { + jwt: jwtToken, + id: chain, + network, + }); + expect(res.body.message).to.be.equal( + UpdateCommunityErrors.CantChangeNetwork, + ); }); test('should fail to update custom domain if not site admin', async () => { const custom_domain = 'test.com'; - const res = await chai - .request(server.app) - .patch(`/api/communities/${chain}`) - .set('Accept', 'application/json') - .send({ jwt: jwtToken, id: chain, custom_domain }); - expect(res.body.error).to.not.be.null; - expect(res.body.error).to.be.equal(ChainError.CantChangeCustomDomain); + const res = await update(server.app, loggedInAddr, { + jwt: jwtToken, + id: chain, + custom_domain, + }); + expect(res.body.message).to.be.equal( + UpdateCommunityErrors.CantChangeCustomDomain, + ); }); test('should update custom domain if site admin', async () => { const custom_domain = 'test.com'; - const res = await chai - .request(server.app) - .patch(`/api/communities/${chain}`) - .set('Accept', 'application/json') - .send({ jwt: siteAdminJwt, id: chain, custom_domain }); - expect(res.body.status).to.be.equal('Success'); - expect(res.body.result.custom_domain).to.be.equal(custom_domain); + const res = await update(server.app, loggedInAddr, { + jwt: siteAdminJwt, + id: chain, + custom_domain, + }); + expect(res.status).to.be.equal(200); + expect(res.body.custom_domain).to.be.equal(custom_domain); }); test('should fail if no chain id', async () => { const name = 'ethereum-testnet'; - const res = await chai - .request(server.app) - .patch(`/api/communities/${chain}`) - .set('Accept', 'application/json') - .send({ jwt: jwtToken, name }); - expect(res.body.error).to.not.be.null; - expect(res.body.error).to.be.equal(ChainError.NoCommunityId); + const res = await update(server.app, loggedInAddr, { + jwt: jwtToken, + name, + }); + expect(res.body.message).to.not.be.null; }); test('should fail if no chain found', async () => { const id = 'ethereum-testnet'; - const res = await chai - .request(server.app) - .patch(`/api/communities/${chain}`) - .set('Accept', 'application/json') - .send({ jwt: jwtToken, id }); - expect(res.body.error).to.not.be.null; - expect(res.body.error).to.be.equal(ChainError.NoCommunityFound); + const res = await update(server.app, loggedInAddr, { jwt: jwtToken, id }); + expect(res.body.message).to.not.be.null; }); test('should fail if not admin ', async () => { @@ -260,13 +270,10 @@ describe('Update Community/Chain Tests', () => { { id: result.user_id, email: result.email }, config.AUTH.JWT_SECRET, ); - const res = await chai - .request(server.app) - .patch(`/api/communities/${chain}`) - .set('Accept', 'application/json') - .send({ jwt: newJwt, id }); - expect(res.body.error).to.not.be.null; - expect(res.body.error).to.be.equal(ChainError.NotAdmin); + const res = await update(server.app, loggedInAddr, { jwt: newJwt, id }); + expect(res.body.message).to.be.equal( + 'User is not admin in the community', + ); }); }); @@ -275,7 +282,7 @@ describe('Update Community/Chain Tests', () => { const name = 'commonwealth tester community'; const res = await chai .request(server.app) - .patch(``) + .post(``) .set('Accept', 'application/json') .send({ jwt: jwtToken, id: chain, name }); expect(res.body.status).to.be.equal('Success'); @@ -286,7 +293,7 @@ describe('Update Community/Chain Tests', () => { const description = 'for me! and the tester community'; const res = await chai .request(server.app) - .patch(``) + .post(``) .set('Accept', 'application/json') .send({ jwt: jwtToken, id: chain, description }); expect(res.body.status).to.be.equal('Success'); @@ -297,7 +304,7 @@ describe('Update Community/Chain Tests', () => { const website = 'http://edgewa.re'; const res = await chai .request(server.app) - .patch(``) + .post(``) .set('Accept', 'application/json') .send({ jwt: jwtToken, id: chain, website }); expect(res.body.status).to.be.equal('Success'); @@ -308,7 +315,7 @@ describe('Update Community/Chain Tests', () => { const discord = 'http://discord.gg'; const res = await chai .request(server.app) - .patch(``) + .post(``) .set('Accept', 'application/json') .send({ jwt: jwtToken, id: chain, discord }); expect(res.body.status).to.be.equal('Success'); @@ -319,7 +326,7 @@ describe('Update Community/Chain Tests', () => { const telegram = 'https://t.me/'; const res = await chai .request(server.app) - .patch(``) + .post(``) .set('Accept', 'application/json') .send({ jwt: jwtToken, id: chain, telegram }); expect(res.body.status).to.be.equal('Success'); @@ -330,7 +337,7 @@ describe('Update Community/Chain Tests', () => { const github = 'https://github.com/'; const res = await chai .request(server.app) - .patch(``) + .post(``) .set('Accept', 'application/json') .send({ jwt: jwtToken, id: chain, github }); expect(res.body.status).to.be.equal('Success'); diff --git a/packages/commonwealth/test/util/modelUtils.ts b/packages/commonwealth/test/util/modelUtils.ts index 00d6d049657..edebdae9523 100644 --- a/packages/commonwealth/test/util/modelUtils.ts +++ b/packages/commonwealth/test/util/modelUtils.ts @@ -613,12 +613,12 @@ export const modelSeeder = (app: Application, models: DB): ModelSeeder => ({ .request(app) .post(`/api/v1/CreateCommunity`) .set('Accept', 'application/json') - //.set('address', address.split(':')[2]) + .set('address', args.creator_address) .send({ ...args, type: 'offchain', base: 'ethereum', - eth_chain_id: 1, + eth_chain_id: 2, user_address: args.creator_address, node_url: 'http://chain.url', network: 'network',