From b16c49287e3da4cbbb89d4c7f0cc103bd91b5623 Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Thu, 10 Oct 2024 21:11:13 +0500 Subject: [PATCH 01/27] Added `GroupTopicPermissions` entity --- libs/model/src/models/associations.ts | 14 +++++- libs/model/src/models/factories.ts | 2 + libs/model/src/models/groupTopicPermission.ts | 46 +++++++++++++++++++ libs/model/src/models/index.ts | 1 + .../entities/groupTopicPermission.schemas.ts | 18 ++++++++ libs/schemas/src/entities/index.ts | 1 + ...02720-add-group-topic-permissions-table.js | 42 +++++++++++++++++ 7 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 libs/model/src/models/groupTopicPermission.ts create mode 100644 libs/schemas/src/entities/groupTopicPermission.schemas.ts create mode 100644 packages/commonwealth/server/migrations/20241010102720-add-group-topic-permissions-table.js diff --git a/libs/model/src/models/associations.ts b/libs/model/src/models/associations.ts index 873be2f2669..9de79d0bad0 100644 --- a/libs/model/src/models/associations.ts +++ b/libs/model/src/models/associations.ts @@ -86,7 +86,13 @@ export const buildAssociations = (db: DB) => { asMany: 'threads', onUpdate: 'CASCADE', onDelete: 'SET NULL', - }).withMany(db.ContestTopic, { asMany: 'contest_topics' }); + }) + .withMany(db.ContestTopic, { asMany: 'contest_topics' }) + .withMany(db.GroupTopicPermission, { + foreignKey: 'topic_id', + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }); db.Thread.withMany(db.Poll) .withMany(db.ContestAction, { @@ -127,7 +133,11 @@ export const buildAssociations = (db: DB) => { onDelete: 'CASCADE', }); - db.Group.withMany(db.GroupPermission); + db.Group.withMany(db.GroupPermission).withMany(db.GroupTopicPermission, { + foreignKey: 'group_id', + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }); // Many-to-many associations (cross-references) db.Membership.withManyToMany( diff --git a/libs/model/src/models/factories.ts b/libs/model/src/models/factories.ts index b92197182b1..6888451fbbb 100644 --- a/libs/model/src/models/factories.ts +++ b/libs/model/src/models/factories.ts @@ -24,6 +24,7 @@ import EmailUpdateToken from './email_update_token'; import EvmEventSource from './evmEventSource'; import Group from './group'; import GroupPermission from './groupPermission'; +import GroupTopicPermission from './groupTopicPermission'; import LastProcessedEvmBlock from './lastProcessedEvmBlock'; import Membership from './membership'; import Outbox from './outbox'; @@ -89,6 +90,7 @@ export const Factories = { Webhook, Wallets, XpLog, + GroupTopicPermission, }; export type DB = { diff --git a/libs/model/src/models/groupTopicPermission.ts b/libs/model/src/models/groupTopicPermission.ts new file mode 100644 index 00000000000..0d8454fb838 --- /dev/null +++ b/libs/model/src/models/groupTopicPermission.ts @@ -0,0 +1,46 @@ +import { GroupTopicPermission } from '@hicommonwealth/schemas'; +import Sequelize from 'sequelize'; +import { z } from 'zod'; +import { GroupAttributes } from './group'; +import { TopicAttributes } from './topic'; +import type { ModelInstance } from './types'; + +export type GroupTopicPermissionAttributes = z.infer< + typeof GroupTopicPermission +> & { + // associations + Group?: GroupAttributes; + Topic?: TopicAttributes; +}; + +export type GroupTopicPermissionInstance = + ModelInstance; + +export default ( + sequelize: Sequelize.Sequelize, +): Sequelize.ModelStatic => + sequelize.define( + 'GroupTopicPermission', + { + group_id: { + type: Sequelize.INTEGER, + allowNull: false, + primaryKey: true, + }, + topic_id: { + type: Sequelize.INTEGER, + allowNull: false, + primaryKey: true, + }, + allowed_actions: { + type: Sequelize.STRING, + allowNull: false, + }, + }, + { + tableName: 'GroupTopicPermissions', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + }, + ); diff --git a/libs/model/src/models/index.ts b/libs/model/src/models/index.ts index 99c49cf8464..bf06e279662 100644 --- a/libs/model/src/models/index.ts +++ b/libs/model/src/models/index.ts @@ -61,6 +61,7 @@ export * from './discord_bot_config'; export * from './email_update_token'; export * from './evmEventSource'; export * from './group'; +export * from './groupTopicPermission'; export * from './lastProcessedEvmBlock'; export * from './membership'; export * from './outbox'; diff --git a/libs/schemas/src/entities/groupTopicPermission.schemas.ts b/libs/schemas/src/entities/groupTopicPermission.schemas.ts new file mode 100644 index 00000000000..cd32e7db027 --- /dev/null +++ b/libs/schemas/src/entities/groupTopicPermission.schemas.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import { PG_INT } from '../utils'; + +export enum GroupTopicPermissionEnum { + UPVOTE = 'UPVOTE', + UPVOTE_AND_COMMENT = 'UPVOTE_AND_COMMENT', + UPVOTE_AND_COMMENT_AND_POST = 'UPVOTE_AND_COMMENT_AND_POST', +} + +export type GroupTopicPermissionAction = keyof typeof GroupTopicPermissionEnum; + +export const GroupTopicPermission = z.object({ + group_id: PG_INT, + topic_id: PG_INT, + allowed_actions: z.nativeEnum(GroupTopicPermissionEnum), + created_at: z.coerce.date().optional(), + updated_at: z.coerce.date().optional(), +}); diff --git a/libs/schemas/src/entities/index.ts b/libs/schemas/src/entities/index.ts index 2180aae9b3a..d98ea66652c 100644 --- a/libs/schemas/src/entities/index.ts +++ b/libs/schemas/src/entities/index.ts @@ -5,6 +5,7 @@ export * from './contract.schemas'; export * from './discordBotConfig.schemas'; export * from './group-permission.schemas'; export * from './group.schemas'; +export * from './groupTopicPermission.schemas'; export * from './notification.schemas'; export * from './reaction.schemas'; export * from './snapshot.schemas'; diff --git a/packages/commonwealth/server/migrations/20241010102720-add-group-topic-permissions-table.js b/packages/commonwealth/server/migrations/20241010102720-add-group-topic-permissions-table.js new file mode 100644 index 00000000000..e6109b1c8a4 --- /dev/null +++ b/packages/commonwealth/server/migrations/20241010102720-add-group-topic-permissions-table.js @@ -0,0 +1,42 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (t) => { + await queryInterface.createTable( + 'GroupTopicPermissions', + { + group_id: { + type: Sequelize.INTEGER, + allowNull: false, + primaryKey: true, + references: { model: 'Groups', key: 'id' }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + topic_id: { + type: Sequelize.INTEGER, + allowNull: false, + primaryKey: true, + references: { model: 'Topics', key: 'id' }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + allowed_actions: { type: Sequelize.STRING, allowNull: false }, + created_at: { type: Sequelize.DATE, allowNull: false }, + updated_at: { type: Sequelize.DATE, allowNull: false }, + }, + { + timestamps: true, + transactions: t, + }, + ); + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.dropTable('GroupTopicPermissions', { transaction }); + }); + }, +}; From edf786ae7000cf245d5d66e8a007dda32c9ec83d Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Thu, 10 Oct 2024 21:17:08 +0500 Subject: [PATCH 02/27] Added logic to create group with topic level permissions --- .../src/community/CreateGroup.command.ts | 18 +++- .../schemas/src/commands/community.schemas.ts | 10 +- .../scripts/state/api/groups/createGroup.ts | 9 +- .../Groups/common/GroupForm/GroupForm.scss | 12 +++ .../Groups/common/GroupForm/GroupForm.tsx | 92 +++++++++++++++++-- .../TopicPermissionsSubForm.scss | 23 +++++ .../TopicPermissionsSubForm.tsx | 33 +++++++ .../TopicPermissionsSubForm/index.tsx | 1 + .../Groups/common/GroupForm/constants.ts | 22 +++++ .../Groups/common/GroupForm/index.types.ts | 26 +++++- .../Groups/common/helpers/index.ts | 2 +- 11 files changed, 233 insertions(+), 15 deletions(-) create mode 100644 packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/TopicPermissionsSubForm/TopicPermissionsSubForm.scss create mode 100644 packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/TopicPermissionsSubForm/TopicPermissionsSubForm.tsx create mode 100644 packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/TopicPermissionsSubForm/index.tsx create mode 100644 packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/constants.ts diff --git a/libs/model/src/community/CreateGroup.command.ts b/libs/model/src/community/CreateGroup.command.ts index f863cc1d8e6..810003bc022 100644 --- a/libs/model/src/community/CreateGroup.command.ts +++ b/libs/model/src/community/CreateGroup.command.ts @@ -25,7 +25,7 @@ export function CreateGroup(): Command< const topics = await models.Topic.findAll({ where: { - id: { [Op.in]: payload.topics || [] }, + id: { [Op.in]: payload.topics?.map((t) => t.id) || [] }, community_id, }, }); @@ -70,6 +70,22 @@ export function CreateGroup(): Command< transaction, }, ); + + // add topic level interaction permissions for current group + await Promise.all( + (payload.topics || [])?.map(async (t) => { + if (group.id) { + await models.GroupTopicPermission.create( + { + group_id: group.id, + topic_id: t.id, + allowed_actions: t.permission, + }, + { transaction }, + ); + } + }), + ); } return group.toJSON(); }, diff --git a/libs/schemas/src/commands/community.schemas.ts b/libs/schemas/src/commands/community.schemas.ts index 05953b63b97..05e11c59116 100644 --- a/libs/schemas/src/commands/community.schemas.ts +++ b/libs/schemas/src/commands/community.schemas.ts @@ -12,6 +12,7 @@ import { z } from 'zod'; import { Community, Group, + GroupTopicPermissionEnum, Requirement, StakeTransaction, Topic, @@ -228,7 +229,14 @@ export const CreateGroup = { community_id: z.string(), metadata: GroupMetadata, requirements: z.array(Requirement).optional(), - topics: z.array(PG_INT).optional(), + topics: z + .array( + z.object({ + id: PG_INT, + permission: z.nativeEnum(GroupTopicPermissionEnum), + }), + ) + .optional(), }), output: Community.extend({ groups: z.array(Group).optional() }).partial(), }; diff --git a/packages/commonwealth/client/scripts/state/api/groups/createGroup.ts b/packages/commonwealth/client/scripts/state/api/groups/createGroup.ts index 9989411ebda..7d27c1085a2 100644 --- a/packages/commonwealth/client/scripts/state/api/groups/createGroup.ts +++ b/packages/commonwealth/client/scripts/state/api/groups/createGroup.ts @@ -1,11 +1,12 @@ -import { trpc } from 'client/scripts/utils/trpcClient'; import { ApiEndpoints, queryClient } from 'state/api/config'; +import { trpc } from 'utils/trpcClient'; +import { GroupFormTopicSubmitValues } from 'views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/index.types'; interface CreateGroupProps { communityId: string; address: string; groupName: string; - topicIds: number[]; + topics: GroupFormTopicSubmitValues[]; groupDescription?: string; requirementsToFulfill: number | undefined; requirements?: any[]; @@ -16,7 +17,7 @@ export const buildCreateGroupInput = ({ communityId, groupName, groupDescription, - topicIds, + topics, requirementsToFulfill, requirements = [], }: CreateGroupProps) => { @@ -32,7 +33,7 @@ export const buildCreateGroupInput = ({ }), }, requirements, - topics: topicIds, + topics, }; }; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.scss b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.scss index 92a7056367e..94dd4e13a16 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.scss +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.scss @@ -43,6 +43,18 @@ color: $neutral-500; } } + + .topic-permission-header { + padding: 0 12px; + + @include extraSmall { + padding: 0 8px; + } + } + + .divider-spacing { + margin: -10px 0px; + } } .action-buttons { diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.tsx index 747e49526c8..c6859d67e96 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.tsx @@ -25,11 +25,19 @@ import { import Allowlist from './Allowlist'; import './GroupForm.scss'; import RequirementSubForm from './RequirementSubForm'; +import TopicPermissionsSubForm from './TopicPermissionsSubForm'; +import { + REQUIREMENTS_TO_FULFILL, + REVERSED_TOPIC_PERMISSIONS, + TOPIC_PERMISSIONS, +} from './constants'; import { FormSubmitValues, GroupFormProps, RequirementSubFormsState, RequirementSubType, + TopicPermissions, + TopicPermissionsSubFormsState, } from './index.types'; import { VALIDATION_MESSAGES, @@ -37,11 +45,6 @@ import { requirementSubFormValidationSchema, } from './validations'; -const REQUIREMENTS_TO_FULFILL = { - ALL_REQUIREMENTS: 'ALL', - N_REQUIREMENTS: 'N', -}; - type CWRequirementsRadioButtonProps = { maxRequirements: number; inputValue: string; @@ -170,6 +173,9 @@ const GroupForm = ({ const [requirementSubForms, setRequirementSubForms] = useState< RequirementSubFormsState[] >([]); + const [topicPermissionsSubForms, setTopicPermissionsSubForms] = useState< + TopicPermissionsSubFormsState[] + >([]); useEffect(() => { if (initialValues.requirements) { @@ -371,6 +377,10 @@ const GroupForm = ({ const formValues = { ...values, + topics: topicPermissionsSubForms.map((t) => ({ + id: t.topic.id, + permission: REVERSED_TOPIC_PERMISSIONS[t.permission], + })), requirementsToFulfill, requirements: requirementSubForms.map((x) => x.values), }; @@ -378,6 +388,31 @@ const GroupForm = ({ await onSubmit(formValues); }; + const handleWatchForm = (values: FormSubmitValues) => { + if (values?.topics?.length > 0) { + setTopicPermissionsSubForms( + values.topics.map((topic) => ({ + topic: { + id: parseInt(`${topic.value}`), + name: topic.label, + }, + permission: TOPIC_PERMISSIONS.UPVOTE_AND_COMMENT_AND_POST, + })), + ); + } else { + setTopicPermissionsSubForms([]); + } + }; + + const updateTopicPermissionByIndex = ( + index: number, + newPermission: TopicPermissions, + ) => { + const updatedTopicPermissionsSubForms = [...topicPermissionsSubForms]; + updatedTopicPermissionsSubForms[index].permission = newPermission; + setTopicPermissionsSubForms([...updatedTopicPermissionsSubForms]); + }; + // + 1 for allowlists const maxRequirements = requirementSubForms.length + 1; @@ -394,11 +429,16 @@ const GroupForm = ({ ? REQUIREMENTS_TO_FULFILL.ALL_REQUIREMENTS : REQUIREMENTS_TO_FULFILL.N_REQUIREMENTS : '', - topics: initialValues.topics || '', + topics: + initialValues?.topics?.map((t) => ({ + label: t.label, + value: t.value, + })) || '', }} validationSchema={groupValidationSchema} onSubmit={handleSubmit} onErrors={validateSubForms} + onWatch={handleWatchForm} > {({ formState }) => ( <> @@ -567,6 +607,46 @@ const GroupForm = ({ }))} /> + + {/* Sub-section: Gated topic permissions */} + {topicPermissionsSubForms?.length > 0 && ( +
+
+ + Topic Permissions + + + Select which topics this group can create threads and + within. + +
+ + + Topic + + + {topicPermissionsSubForms.map((topicPermission, index) => ( + <> + + + updateTopicPermissionByIndex(index, newPermission) + } + /> + {index === topicPermissionsSubForms.length - 1 && ( + + )} + + ))} +
+ )} ({ + label: permission, + value: permission, +})); + +const TopicPermissionsSubForm = ({ + topic, + defaultPermission, + onPermissionChange, +}: TopicPermissionsSubFormType) => { + return ( +
+ {topic.name} + + p.value === defaultPermission)} + options={permissionMap} + onChange={(option) => option?.value && onPermissionChange(option.value)} + /> +
+ ); +}; + +export default TopicPermissionsSubForm; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/TopicPermissionsSubForm/index.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/TopicPermissionsSubForm/index.tsx new file mode 100644 index 00000000000..15331b9c7ff --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/TopicPermissionsSubForm/index.tsx @@ -0,0 +1 @@ +export { default } from './TopicPermissionsSubForm'; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/constants.ts b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/constants.ts new file mode 100644 index 00000000000..cc27814347b --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/constants.ts @@ -0,0 +1,22 @@ +import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; + +export const REQUIREMENTS_TO_FULFILL = { + ALL_REQUIREMENTS: 'ALL', + N_REQUIREMENTS: 'N', +}; + +export const TOPIC_PERMISSIONS = { + [GroupTopicPermissionEnum.UPVOTE]: 'Upvote', + [GroupTopicPermissionEnum.UPVOTE_AND_COMMENT]: 'Upvote & Comment', + [GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST]: + 'Upvote & Comment & Post', +}; + +type ReversedTopicPermissions = { + [K in keyof typeof TOPIC_PERMISSIONS as (typeof TOPIC_PERMISSIONS)[K]]: K; +}; + +export const REVERSED_TOPIC_PERMISSIONS: ReversedTopicPermissions = + Object.fromEntries( + Object.entries(TOPIC_PERMISSIONS).map(([key, value]) => [value, key]), + ) as ReversedTopicPermissions; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/index.types.ts b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/index.types.ts index e24198d10e8..17f04e35fa4 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/index.types.ts +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/index.types.ts @@ -1,3 +1,6 @@ +import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; +import { TOPIC_PERMISSIONS } from './constants'; + export type RequirementSubFormsState = { defaultValues?: RequirementSubTypeWithLabel; values: RequirementSubType; @@ -13,6 +16,20 @@ export type RequirementSubType = { requirementTokenId?: string; }; +export type TopicPermissions = + (typeof TOPIC_PERMISSIONS)[keyof typeof TOPIC_PERMISSIONS]; + +export type TopicPermissionsSubFormsState = { + topic: TopicPermissionsSubFormType['topic']; + permission: TopicPermissions; +}; + +export type TopicPermissionsSubFormType = { + topic: { id: number; name: string }; + defaultPermission?: TopicPermissions; + onPermissionChange: (permission: string) => void; +}; + export type LabelType = { label: string; value: string; @@ -35,12 +52,17 @@ export type RequirementSubFormType = { onChange: (values: RequirementSubType) => any; }; +export type GroupFormTopicSubmitValues = { + id: number; + permission: GroupTopicPermissionEnum; +}; + export type GroupResponseValuesType = { groupName: string; groupDescription?: string; requirementsToFulfill: 'ALL' | number; requirements?: RequirementSubType[]; - topics: LabelType[]; + topics: GroupFormTopicSubmitValues[]; allowlist?: number[]; }; @@ -49,7 +71,7 @@ export type GroupInitialValuesTypeWithLabel = { groupDescription?: string; requirements?: RequirementSubTypeWithLabel[]; requirementsToFulfill?: 'ALL' | number; - topics: LabelType[]; + topics: (LabelType & { permission: GroupTopicPermissionEnum })[]; }; export type FormSubmitValues = { diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/helpers/index.ts b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/helpers/index.ts index 905e1476203..ec6b4b5b6b0 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/helpers/index.ts +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/helpers/index.ts @@ -23,7 +23,7 @@ export const makeGroupDataBaseAPIPayload = ( address: userStore.getState().activeAccount?.address || '', groupName: formSubmitValues.groupName.trim(), groupDescription: (formSubmitValues.groupDescription || '').trim(), - topicIds: formSubmitValues.topics.map((x) => parseInt(x.value)), + topics: formSubmitValues.topics, requirementsToFulfill: formSubmitValues.requirementsToFulfill === 'ALL' ? // @ts-expect-error StrictNullChecks From de7d3325db4e08098efd55531cbe9e775b60e519 Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Thu, 10 Oct 2024 21:18:53 +0500 Subject: [PATCH 03/27] Added logic to update group with topic level permissions --- .../src/community/UpdateGroup.command.ts | 22 ++++++++++++++++++- .../schemas/src/commands/community.schemas.ts | 9 +++++++- .../scripts/state/api/groups/editGroup.ts | 9 ++++---- .../Update/UpdateCommunityGroupPage.tsx | 1 + .../Groups/common/GroupForm/GroupForm.tsx | 9 ++++++++ 5 files changed, 44 insertions(+), 6 deletions(-) diff --git a/libs/model/src/community/UpdateGroup.command.ts b/libs/model/src/community/UpdateGroup.command.ts index 25461556365..ffe06a9623f 100644 --- a/libs/model/src/community/UpdateGroup.command.ts +++ b/libs/model/src/community/UpdateGroup.command.ts @@ -33,7 +33,7 @@ export function UpdateGroup(): Command< const topics = await models.Topic.findAll({ where: { - id: { [Op.in]: payload.topics || [] }, + id: { [Op.in]: payload.topics?.map((t) => t.id) || [] }, community_id, }, }); @@ -90,6 +90,26 @@ export function UpdateGroup(): Command< transaction, }, ); + + // update topic level interaction permissions for current group + await Promise.all( + (payload.topics || [])?.map(async (t) => { + if (group.id) { + await models.GroupTopicPermission.update( + { + allowed_actions: t.permission, + }, + { + where: { + group_id: group_id, + topic_id: t.id, + }, + transaction, + }, + ); + } + }), + ); } return group.toJSON(); diff --git a/libs/schemas/src/commands/community.schemas.ts b/libs/schemas/src/commands/community.schemas.ts index 05e11c59116..44e3a7f7969 100644 --- a/libs/schemas/src/commands/community.schemas.ts +++ b/libs/schemas/src/commands/community.schemas.ts @@ -247,7 +247,14 @@ export const UpdateGroup = { group_id: PG_INT, metadata: GroupMetadata.optional(), requirements: z.array(Requirement).optional(), - topics: z.array(PG_INT).optional(), + topics: z + .array( + z.object({ + id: PG_INT, + permission: z.nativeEnum(GroupTopicPermissionEnum), + }), + ) + .optional(), }), output: Group.partial(), }; diff --git a/packages/commonwealth/client/scripts/state/api/groups/editGroup.ts b/packages/commonwealth/client/scripts/state/api/groups/editGroup.ts index 35313e768e2..1e516d6fa9d 100644 --- a/packages/commonwealth/client/scripts/state/api/groups/editGroup.ts +++ b/packages/commonwealth/client/scripts/state/api/groups/editGroup.ts @@ -1,4 +1,5 @@ -import { trpc } from 'client/scripts/utils/trpcClient'; +import { trpc } from 'utils/trpcClient'; +import { GroupFormTopicSubmitValues } from 'views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/index.types'; import { userStore } from '../../ui/user'; import { ApiEndpoints, queryClient } from '../config'; @@ -8,7 +9,7 @@ interface EditGroupProps { address: string; groupName: string; groupDescription?: string; - topicIds: number[]; + topics: GroupFormTopicSubmitValues[]; requirementsToFulfill: number | undefined; requirements?: any[]; } @@ -19,7 +20,7 @@ export const buildUpdateGroupInput = ({ address, groupName, groupDescription, - topicIds, + topics, requirementsToFulfill, requirements, }: EditGroupProps) => { @@ -37,7 +38,7 @@ export const buildUpdateGroupInput = ({ }), }, ...(requirements && { requirements }), - topics: topicIds, + topics, }; }; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/Update/UpdateCommunityGroupPage.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/Update/UpdateCommunityGroupPage.tsx index fb93b2b7c2c..35a1d021ec1 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/Update/UpdateCommunityGroupPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/Update/UpdateCommunityGroupPage.tsx @@ -131,6 +131,7 @@ const UpdateCommunityGroupPage = ({ groupId }: { groupId: string }) => { topics: (foundGroup.topics || []).map((topic) => ({ label: topic.name, value: topic.id, + permission: topic.permission, })), }} onSubmit={(values) => { diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.tsx index c6859d67e96..d9f70414617 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.tsx @@ -209,6 +209,15 @@ const GroupForm = ({ `${initialValues.requirementsToFulfill}`, ); } + + if (initialValues.topics) { + setTopicPermissionsSubForms( + initialValues.topics.map((t) => ({ + permission: TOPIC_PERMISSIONS[t.permission], + topic: { id: parseInt(`${t.value}`), name: t.label }, + })), + ); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); From abd1584f41720c6f1925ccf83a4ffb57aab178ed Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Thu, 10 Oct 2024 21:40:14 +0500 Subject: [PATCH 04/27] Added logic to show group gated topic level permissions --- .../GroupsSection/GroupCard/GroupCard.scss | 24 ++++++++-- .../GroupsSection/GroupCard/GroupCard.tsx | 45 +++++++++---------- .../Members/GroupsSection/GroupCard/types.ts | 24 ++++++++++ .../Members/GroupsSection/GroupsSection.tsx | 3 ++ .../server_groups_methods/get_groups.ts | 27 +++++++++-- 5 files changed, 92 insertions(+), 31 deletions(-) create mode 100644 packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/types.ts diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.scss b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.scss index bc01ce00a87..e38570fc430 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.scss +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.scss @@ -35,18 +35,36 @@ color: $neutral-500; font-size: 18px; } + .caption { color: $neutral-500; } } } - .gating-tags { + .gating-topics { display: flex; - flex-wrap: wrap; + flex-direction: column; gap: 8px; - align-items: center; width: 100%; + + .row { + padding: 8px 0; + width: 100%; + display: grid; + grid-template-columns: 1fr 1fr; + + .Tag { + height: auto; + + @include extraSmall { + .Text { + word-wrap: break-word; + white-space: normal; + } + } + } + } } .allowlist-table { diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.tsx index 7d3d878c732..31aa6c2a5d7 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.tsx @@ -1,5 +1,4 @@ import useBrowserWindow from 'hooks/useBrowserWindow'; -import MinimumProfile from 'models/MinimumProfile'; import React, { useState } from 'react'; import { Link } from 'react-router-dom'; import { Avatar } from 'views/components/Avatar'; @@ -9,32 +8,13 @@ import { CWText } from 'views/components/component_kit/cw_text'; import { CWTag } from 'views/components/component_kit/new_designs/CWTag'; import { formatAddressShort } from '../../../../../../helpers'; import CWPagination from '../../../../../components/component_kit/new_designs/CWPagination/CWPagination'; +import { TOPIC_PERMISSIONS } from '../../../Groups/common/GroupForm/constants'; import './GroupCard.scss'; import RequirementCard from './RequirementCard/RequirementCard'; - -type RequirementCardProps = { - requirementType: string; - requirementChain: string; - requirementContractAddress?: string; - requirementCondition: string; - requirementAmount: string; - requirementTokenId?: string; -}; - -type GroupCardProps = { - isJoined?: boolean; - groupName: string; - groupDescription?: string; - requirements?: RequirementCardProps[]; // This represents erc requirements - requirementsToFulfill: 'ALL' | number; - allowLists?: string[]; - topics: { id: number; name: string }[]; - canEdit?: boolean; - onEditClick?: () => any; - profiles?: Map; -}; +import { GroupCardProps } from './types'; const ALLOWLIST_MEMBERS_PER_PAGE = 7; + const GroupCard = ({ isJoined, groupName, @@ -163,9 +143,24 @@ const GroupCard = ({ {topics.length > 0 && ( <> Gated Topics -
+
+
+ Topic + Permission +
{topics.map((t, index) => ( - +
+ +
+ {t.name} + + +
+ +
))}
diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/types.ts b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/types.ts new file mode 100644 index 00000000000..5066de8776c --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/types.ts @@ -0,0 +1,24 @@ +import MinimumProfile from 'models/MinimumProfile'; +import { TopicPermissions } from '../../../Groups/common/GroupForm/index.types'; + +export type RequirementCardProps = { + requirementType: string; + requirementChain: string; + requirementContractAddress?: string; + requirementCondition: string; + requirementAmount: string; + requirementTokenId?: string; +}; + +export type GroupCardProps = { + isJoined?: boolean; + groupName: string; + groupDescription?: string; + requirements?: RequirementCardProps[]; // This represents erc requirements + requirementsToFulfill: 'ALL' | number; + allowLists?: string[]; + topics: { id: number; name: string; permission?: TopicPermissions }[]; + canEdit?: boolean; + onEditClick?: () => any; + profiles?: Map; +}; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupsSection.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupsSection.tsx index efebb54f3ea..a6717a709b7 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupsSection.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupsSection.tsx @@ -40,6 +40,8 @@ const GroupsSection = ({ profiles?.map((p) => [p.address, p]), ); + console.log('filteredGroups => ', filteredGroups); + return (
{hasNoGroups && } @@ -92,6 +94,7 @@ const GroupsSection = ({ topics={(group?.topics || []).map((x) => ({ id: x.id, name: x.name, + permission: x.permission, }))} canEdit={canManageGroups} onEditClick={() => navigate(`/members/groups/${group.id}/update`)} diff --git a/packages/commonwealth/server/controllers/server_groups_methods/get_groups.ts b/packages/commonwealth/server/controllers/server_groups_methods/get_groups.ts index aa7fb05078f..2ec088d4bb1 100644 --- a/packages/commonwealth/server/controllers/server_groups_methods/get_groups.ts +++ b/packages/commonwealth/server/controllers/server_groups_methods/get_groups.ts @@ -3,6 +3,7 @@ import { MembershipAttributes, TopicAttributes, } from '@hicommonwealth/model'; +import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; import { Op, WhereOptions } from 'sequelize'; import { ServerGroupsController } from '../server_groups_controller'; @@ -12,9 +13,13 @@ export type GetGroupsOptions = { includeTopics?: boolean; }; +export type TopicAttributesWithPermission = TopicAttributes & { + permission: GroupTopicPermissionEnum; +}; + type GroupWithExtras = GroupAttributes & { memberships?: MembershipAttributes[]; - topics?: TopicAttributes[]; + topics?: TopicAttributesWithPermission[]; }; export type GetGroupsResult = GroupWithExtras[]; @@ -26,6 +31,12 @@ export async function __getGroups( where: { community_id: communityId, }, + include: [ + { + model: this.models.GroupTopicPermission, + attributes: ['topic_id', 'allowed_actions'], + }, + ], }); let groupsResult = groups.map((group) => group.toJSON() as GroupWithExtras); @@ -68,11 +79,21 @@ export async function __getGroups( }, }, }); + groupsResult = groupsResult.map((group) => ({ ...group, topics: topics - .map((t) => t.toJSON()) - .filter((t) => t.group_ids!.includes(group.id!)), + .filter((t) => t.group_ids!.includes(group.id!)) + .map((t) => { + const temp: TopicAttributesWithPermission = { ...t.toJSON() }; + temp.permission = + ((group as any).GroupTopicPermissions || []).find( + (gtp) => gtp.topic_id === t.id, + )?.allowed_actions || + // TODO: this fallback should be via a migration for existing communities + GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST; + return temp; + }), })); } From e5f022c3c4efec553569ae9f75257162c5c3c5ae Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Thu, 10 Oct 2024 22:11:05 +0500 Subject: [PATCH 05/27] Updated `/refresh-memberships` response to include topic level permissioning --- .../state/api/groups/refreshMembership.ts | 5 +++-- .../NewThreadFormLegacy/NewThreadForm.tsx | 6 +++--- .../NewThreadFormModern/NewThreadForm.tsx | 4 ++-- .../CWContentPage/CWContentPage.tsx | 7 +++---- .../client/scripts/views/components/feed.tsx | 5 +++-- .../Members/GroupsSection/GroupsSection.tsx | 2 -- .../pages/discussions/DiscussionsPage.tsx | 4 ++-- .../views/pages/overview/TopicSummaryRow.tsx | 4 ++-- .../pages/view_thread/ViewThreadPage.tsx | 7 +++---- .../server_groups_methods/get_groups.ts | 15 ++++++++++--- .../refresh_membership.ts | 21 +++++++++++++++++-- 11 files changed, 52 insertions(+), 28 deletions(-) diff --git a/packages/commonwealth/client/scripts/state/api/groups/refreshMembership.ts b/packages/commonwealth/client/scripts/state/api/groups/refreshMembership.ts index d35d70ee31f..2f38f036e3b 100644 --- a/packages/commonwealth/client/scripts/state/api/groups/refreshMembership.ts +++ b/packages/commonwealth/client/scripts/state/api/groups/refreshMembership.ts @@ -1,3 +1,4 @@ +import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; import { ApiEndpoints, SERVER_URL } from 'state/api/config'; @@ -14,7 +15,7 @@ interface RefreshMembershipProps { export interface Memberships { groupId: number; - topicIds: number[]; + topics: { id: number; permission: GroupTopicPermissionEnum }[]; isAllowed: boolean; rejectReason?: string; } @@ -34,7 +35,7 @@ const refreshMembership = async ({ return response?.data?.result?.map((r) => ({ groupId: r.groupId, - topicIds: r.topicIds, + topics: r.topics, isAllowed: r.allowed, rejectReason: r.rejectReason, })); diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx index 907a8e3dca9..9cf98d969d8 100644 --- a/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx @@ -141,13 +141,13 @@ export const NewThreadForm = () => { const isTopicGated = !!(memberships || []).find( (membership) => - threadTopic?.id && membership.topicIds.includes(threadTopic.id), + threadTopic?.id && membership.topics.find((t) => t.id === threadTopic.id), ); const isActionAllowedInGatedTopic = !!(memberships || []).find( (membership) => - threadTopic.id && + threadTopic && threadTopic?.id && - membership.topicIds.includes(threadTopic?.id) && + membership.topics.find((t) => t.id === threadTopic?.id) && membership.isAllowed, ); const gatedGroupNames = groups diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx index a49282fa545..c566845a28e 100644 --- a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx @@ -134,13 +134,13 @@ export const NewThreadForm = () => { const isTopicGated = !!(memberships || []).find( (membership) => - threadTopic?.id && membership.topicIds.includes(threadTopic.id), + threadTopic?.id && membership.topics.find((t) => t.id === threadTopic.id), ); const isActionAllowedInGatedTopic = !!(memberships || []).find( (membership) => threadTopic.id && threadTopic?.id && - membership.topicIds.includes(threadTopic?.id) && + membership.topics.find((t) => t.id === threadTopic?.id) && membership.isAllowed, ); const gatedGroupNames = groups diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx b/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx index ea04b676a9e..fcd1b7355de 100644 --- a/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx +++ b/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx @@ -132,14 +132,13 @@ export const CWContentPage = ({ }); const isTopicGated = !!(memberships || []).find((membership) => - // @ts-expect-error - membership.topicIds.includes(thread?.topic?.id), + membership.topics.find((t) => t.id === thread?.topic?.id), ); const isActionAllowedInGatedTopic = !!(memberships || []).find( (membership) => - // @ts-expect-error - membership.topicIds.includes(thread?.topic?.id) && membership.isAllowed, + membership.topics.find((t) => t.id === thread?.topic?.id) && + membership.isAllowed, ); const isAdmin = Permissions.isSiteAdmin() || Permissions.isCommunityAdmin(); diff --git a/packages/commonwealth/client/scripts/views/components/feed.tsx b/packages/commonwealth/client/scripts/views/components/feed.tsx index 43416e4208e..4cb11b14f71 100644 --- a/packages/commonwealth/client/scripts/views/components/feed.tsx +++ b/packages/commonwealth/client/scripts/views/components/feed.tsx @@ -66,13 +66,14 @@ const FeedThread = ({ thread }: { thread: Thread }) => { const isTopicGated = !!(memberships || []).find( (membership) => - thread?.topic?.id && membership.topicIds.includes(thread.topic.id), + thread?.topic?.id && + membership.topics.find((t) => t.id === thread.topic.id), ); const isActionAllowedInGatedTopic = !!(memberships || []).find( (membership) => thread?.topic?.id && - membership.topicIds.includes(thread.topic.id) && + membership.topics.find((t) => t.id === thread.topic.id) && membership.isAllowed, ); diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupsSection.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupsSection.tsx index a6717a709b7..585b836a54b 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupsSection.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupsSection.tsx @@ -40,8 +40,6 @@ const GroupsSection = ({ profiles?.map((p) => [p.address, p]), ); - console.log('filteredGroups => ', filteredGroups); - return (
{hasNoGroups && } diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx index 126aa48bf7b..008bb76acdf 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx @@ -209,13 +209,13 @@ const DiscussionsPage = ({ topicName }: DiscussionsPageProps) => { const isTopicGated = !!(memberships || []).find( (membership) => thread?.topic?.id && - membership.topicIds.includes(thread.topic.id), + membership.topics.find((t) => t.id === thread.topic.id), ); const isActionAllowedInGatedTopic = !!(memberships || []).find( (membership) => thread?.topic?.id && - membership.topicIds.includes(thread.topic.id) && + membership.topics.find((t) => t.id === thread.topic.id) && membership.isAllowed, ); diff --git a/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx b/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx index bc402155416..6494c8684c0 100644 --- a/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx +++ b/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx @@ -92,13 +92,13 @@ export const TopicSummaryRow = ({ const isTopicGated = !!(memberships || []).find( (membership) => thread?.topic?.id && - membership.topicIds.includes(thread.topic.id), + membership.topics.find((t) => t.id === thread.topic.id), ); const isActionAllowedInGatedTopic = !!(memberships || []).find( (membership) => thread?.topic?.id && - membership.topicIds.includes(thread.topic.id) && + membership.topics.find((t) => t.id === thread.topic.id) && membership.isAllowed, ); diff --git a/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx b/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx index 807e5f859ac..5e019d3af55 100644 --- a/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx @@ -155,14 +155,13 @@ const ViewThreadPage = ({ identifier }: ViewThreadPageProps) => { }); const isTopicGated = !!(memberships || []).find((membership) => - // @ts-expect-error - membership.topicIds.includes(thread?.topic?.id), + membership.topics.find((t) => t.id === thread?.topic?.id), ); const isActionAllowedInGatedTopic = !!(memberships || []).find( (membership) => - // @ts-expect-error - membership.topicIds.includes(thread?.topic?.id) && membership.isAllowed, + membership.topics.find((t) => t.id === thread?.topic?.id) && + membership.isAllowed, ); const isRestrictedMembership = diff --git a/packages/commonwealth/server/controllers/server_groups_methods/get_groups.ts b/packages/commonwealth/server/controllers/server_groups_methods/get_groups.ts index 2ec088d4bb1..0a56395bbd1 100644 --- a/packages/commonwealth/server/controllers/server_groups_methods/get_groups.ts +++ b/packages/commonwealth/server/controllers/server_groups_methods/get_groups.ts @@ -1,5 +1,6 @@ import { GroupAttributes, + GroupInstance, MembershipAttributes, TopicAttributes, } from '@hicommonwealth/model'; @@ -23,6 +24,13 @@ type GroupWithExtras = GroupAttributes & { }; export type GetGroupsResult = GroupWithExtras[]; +export type GroupInstanceWithTopicPermissions = GroupInstance & { + GroupTopicPermissions: { + topic_id: number; + allowed_actions: GroupTopicPermissionEnum; + }[]; +}; + export async function __getGroups( this: ServerGroupsController, { communityId, includeMembers, includeTopics }: GetGroupsOptions, @@ -87,9 +95,10 @@ export async function __getGroups( .map((t) => { const temp: TopicAttributesWithPermission = { ...t.toJSON() }; temp.permission = - ((group as any).GroupTopicPermissions || []).find( - (gtp) => gtp.topic_id === t.id, - )?.allowed_actions || + ( + (group as GroupInstanceWithTopicPermissions) + .GroupTopicPermissions || [] + ).find((gtp) => gtp.topic_id === t.id)?.allowed_actions || // TODO: this fallback should be via a migration for existing communities GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST; return temp; diff --git a/packages/commonwealth/server/controllers/server_groups_methods/refresh_membership.ts b/packages/commonwealth/server/controllers/server_groups_methods/refresh_membership.ts index c59552b43c6..2c33f527a93 100644 --- a/packages/commonwealth/server/controllers/server_groups_methods/refresh_membership.ts +++ b/packages/commonwealth/server/controllers/server_groups_methods/refresh_membership.ts @@ -4,9 +4,11 @@ import { MembershipRejectReason, UserInstance, } from '@hicommonwealth/model'; +import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; import { Op } from 'sequelize'; import { refreshMembershipsForAddress } from '../../util/requirementsModule/refreshMembershipsForAddress'; import { ServerGroupsController } from '../server_groups_controller'; +import { GroupInstanceWithTopicPermissions } from './get_groups'; const Errors = { TopicNotFound: 'Topic not found', @@ -32,6 +34,12 @@ export async function __refreshMembership( where: { community_id: address.community_id, }, + include: [ + { + model: this.models.GroupTopicPermission, + attributes: ['topic_id', 'allowed_actions'], + }, + ], }); // optionally filter to only groups associated with topic @@ -63,9 +71,18 @@ export async function __refreshMembership( // transform memberships to result shape const results = memberships.map((membership) => ({ groupId: membership.group_id, - topicIds: topics + topics: topics .filter((t) => t.group_ids!.includes(membership.group_id)) - .map((t) => t.id), + .map((t) => ({ + id: t.id, + permission: + (groups as GroupInstanceWithTopicPermissions[]) + .find((g) => g.id === membership.group_id) + ?.GroupTopicPermissions?.find((gtp) => gtp.topic_id === t.id) + ?.allowed_actions || + // TODO: this fallback should be via a migration for existing communities + GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, + })), allowed: !membership.reject_reason, rejectReason: membership.reject_reason, })); From 161bae88214bc563e28a3351d6f69a2d1e176469 Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Thu, 10 Oct 2024 22:53:35 +0500 Subject: [PATCH 06/27] Block specific thread/comment actions if required conditions are not met by gated topic members --- .../src/comment/CreateComment.command.ts | 5 +- .../comment/CreateCommentReaction.command.ts | 5 +- libs/model/src/middleware/authorization.ts | 52 ++++++++++++++----- libs/model/src/thread/CreateThread.command.ts | 6 ++- .../thread/CreateThreadReaction.command.ts | 1 + 5 files changed, 53 insertions(+), 16 deletions(-) diff --git a/libs/model/src/comment/CreateComment.command.ts b/libs/model/src/comment/CreateComment.command.ts index 2afb1ec5dda..a517d635594 100644 --- a/libs/model/src/comment/CreateComment.command.ts +++ b/libs/model/src/comment/CreateComment.command.ts @@ -33,7 +33,10 @@ export function CreateComment(): Command< return { ...schemas.CreateComment, auth: [ - isAuthorized({ action: schemas.PermissionEnum.CREATE_COMMENT }), + isAuthorized({ + action: schemas.PermissionEnum.CREATE_COMMENT, + topicPermission: schemas.GroupTopicPermissionEnum.UPVOTE_AND_COMMENT, + }), verifyCommentSignature, ], body: async ({ actor, payload, auth }) => { diff --git a/libs/model/src/comment/CreateCommentReaction.command.ts b/libs/model/src/comment/CreateCommentReaction.command.ts index 412e87a0b3f..a23a1dedaad 100644 --- a/libs/model/src/comment/CreateCommentReaction.command.ts +++ b/libs/model/src/comment/CreateCommentReaction.command.ts @@ -13,7 +13,10 @@ export function CreateCommentReaction(): Command< return { ...schemas.CreateCommentReaction, auth: [ - isAuthorized({ action: schemas.PermissionEnum.CREATE_COMMENT_REACTION }), + isAuthorized({ + action: schemas.PermissionEnum.CREATE_COMMENT_REACTION, + topicPermission: schemas.GroupTopicPermissionEnum.UPVOTE, + }), verifyReactionSignature, ], body: async ({ payload, actor, auth }) => { diff --git a/libs/model/src/middleware/authorization.ts b/libs/model/src/middleware/authorization.ts index 2bd482c8378..53991a1b097 100644 --- a/libs/model/src/middleware/authorization.ts +++ b/libs/model/src/middleware/authorization.ts @@ -6,7 +6,11 @@ import { type Context, type Handler, } from '@hicommonwealth/core'; -import { Group, GroupPermissionAction } from '@hicommonwealth/schemas'; +import { + Group, + GroupPermissionAction, + GroupTopicPermissionEnum, +} from '@hicommonwealth/schemas'; import { Role } from '@hicommonwealth/shared'; import { Op, QueryTypes } from 'sequelize'; import { ZodSchema, z } from 'zod'; @@ -49,7 +53,7 @@ export class NonMember extends InvalidActor { constructor( public actor: Actor, public topic: string, - public action: GroupPermissionAction, + public action: GroupPermissionAction | GroupTopicPermissionEnum, ) { super( actor, @@ -182,10 +186,11 @@ async function buildAuth( /** * Checks if actor passes a set of requirements and grants access for all groups of the given topic */ -async function isTopicMember( +async function hasTopicInteractionPermissions( actor: Actor, auth: AuthContext, action: GroupPermissionAction, + topicPermission: GroupTopicPermissionEnum, ): Promise { if (!auth.topic_id) throw new InvalidInput('Must provide a topic id'); @@ -196,13 +201,18 @@ async function isTopicMember( const groups = await models.sequelize.query< z.infer & { - allowed_actions?: GroupPermissionAction[]; + group_allowed_actions?: GroupPermissionAction[]; + topic_allowed_actions?: GroupTopicPermissionEnum; } >( ` - SELECT g.*, gp.allowed_actions + SELECT + g.*, + gp.allowed_actions as group_allowed_actions, + gtp.allowed_actions as topic_allowed_actions FROM "Groups" as g LEFT JOIN "GroupPermissions" gp ON g.id = gp.group_id + LEFT JOIN "GroupTopicPermissions" gtp ON g.id = gtp.group_id AND gtp.topic_id = :topic_id WHERE g.community_id = :community_id AND g.id IN (:group_ids); `, { @@ -211,22 +221,31 @@ async function isTopicMember( replacements: { community_id: auth.topic.community_id, group_ids: auth.topic.group_ids, + topic_id: auth.topic.id, }, }, ); // There are 2 cases here. We either have the old group permission system where the group doesn't have - // any allowed_actions, or we have the new fine-grained permission system where the action must be in - // the allowed_actions list. - const allowed = groups.filter( - (g) => !g.allowed_actions || g.allowed_actions.includes(action), + // any group_allowed_actions, or we have the new fine-grained permission system where the action must be in + // the group_allowed_actions list. + const allowedGroupActions = groups.filter( + (g) => !g.group_allowed_actions || g.group_allowed_actions.includes(action), + ); + if (!allowedGroupActions.length!) + throw new NonMember(actor, auth.topic.name, action); + + // The user must have `topicPermission` matching `topic_allowed_actions` for this topic + const allowedTopicActions = groups.filter((g) => + g.topic_allowed_actions?.includes(topicPermission), ); - if (!allowed.length!) throw new NonMember(actor, auth.topic.name, action); + if (!allowedTopicActions.length!) + throw new NonMember(actor, auth.topic.name, topicPermission); // check membership for all groups of topic const memberships = await models.Membership.findAll({ where: { - group_id: { [Op.in]: allowed.map((g) => g.id!) }, + group_id: { [Op.in]: allowedGroupActions.map((g) => g.id!) }, address_id: auth.address!.id, }, include: [ @@ -272,11 +291,13 @@ export const isSuperAdmin: AuthHandler = async (ctx) => { export function isAuthorized({ roles = ['admin', 'moderator', 'member'], action, + topicPermission, author = false, collaborators = false, }: { roles?: Role[]; action?: GroupPermissionAction; + topicPermission?: GroupTopicPermissionEnum; author?: boolean; collaborators?: boolean; }): AuthHandler { @@ -293,9 +314,14 @@ export function isAuthorized({ if (auth.address!.is_banned) throw new BannedActor(ctx.actor); - if (action) { + if (action && topicPermission) { // waterfall stops here after validating the action - await isTopicMember(ctx.actor, auth, action); + await hasTopicInteractionPermissions( + ctx.actor, + auth, + action, + topicPermission, + ); return; } diff --git a/libs/model/src/thread/CreateThread.command.ts b/libs/model/src/thread/CreateThread.command.ts index 228a55fda7a..d69f611706f 100644 --- a/libs/model/src/thread/CreateThread.command.ts +++ b/libs/model/src/thread/CreateThread.command.ts @@ -89,7 +89,11 @@ export function CreateThread(): Command< return { ...schemas.CreateThread, auth: [ - isAuthorized({ action: schemas.PermissionEnum.CREATE_THREAD }), + isAuthorized({ + action: schemas.PermissionEnum.CREATE_THREAD, + topicPermission: + schemas.GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, + }), verifyThreadSignature, ], body: async ({ actor, payload, auth }) => { diff --git a/libs/model/src/thread/CreateThreadReaction.command.ts b/libs/model/src/thread/CreateThreadReaction.command.ts index 935360ce7ae..8dc4e33d5e5 100644 --- a/libs/model/src/thread/CreateThreadReaction.command.ts +++ b/libs/model/src/thread/CreateThreadReaction.command.ts @@ -19,6 +19,7 @@ export function CreateThreadReaction(): Command< auth: [ isAuthorized({ action: schemas.PermissionEnum.CREATE_THREAD_REACTION, + topicPermission: schemas.GroupTopicPermissionEnum.UPVOTE, }), verifyReactionSignature, ], From 82db2495fff6accc801747ba2ae463ccac893dc0 Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Thu, 10 Oct 2024 23:27:34 +0500 Subject: [PATCH 07/27] Abstracted topic gating membership logic into global hook --- .../client/scripts/hooks/useTopicGating.ts | 50 +++++++++++++++++++ .../NewThreadFormLegacy/NewThreadForm.tsx | 26 ++-------- .../NewThreadFormModern/NewThreadForm.tsx | 26 ++-------- .../CWContentPage/CWContentPage.tsx | 23 ++------- .../client/scripts/views/components/feed.tsx | 27 ++-------- .../common/GroupForm/Allowlist/Allowlist.tsx | 6 +-- .../Members/CommunityMembersPage.tsx | 10 ++-- .../pages/discussions/DiscussionsPage.tsx | 6 +-- .../views/pages/overview/TopicSummaryRow.tsx | 7 +-- .../pages/view_thread/ViewThreadPage.tsx | 24 ++------- 10 files changed, 88 insertions(+), 117 deletions(-) create mode 100644 packages/commonwealth/client/scripts/hooks/useTopicGating.ts diff --git a/packages/commonwealth/client/scripts/hooks/useTopicGating.ts b/packages/commonwealth/client/scripts/hooks/useTopicGating.ts new file mode 100644 index 00000000000..78910c052d7 --- /dev/null +++ b/packages/commonwealth/client/scripts/hooks/useTopicGating.ts @@ -0,0 +1,50 @@ +import { useRefreshMembershipQuery } from 'state/api/groups'; +import Permissions from '../utils/Permissions'; + +type IuseTopicGating = { + communityId: string; + apiEnabled: boolean; + userAddress: string; + topicId?: number; +}; + +const useTopicGating = ({ + apiEnabled, + communityId, + userAddress, + topicId, +}: IuseTopicGating) => { + const { data: memberships = [], isLoading: isLoadingMemberships } = + useRefreshMembershipQuery({ + communityId, + address: userAddress, + apiEnabled, + }); + + const isTopicGated = !!(memberships || []).find((membership) => + membership.topics.find((t) => t.id === topicId), + ); + + const isActionAllowedInGatedTopic = !!(memberships || []).find( + (membership) => + membership.topics.find((t) => t.id === topicId) && membership.isAllowed, + ); + + const isAdmin = Permissions.isSiteAdmin() || Permissions.isCommunityAdmin(); + + const isRestrictedMembership = + !isAdmin && isTopicGated && !isActionAllowedInGatedTopic; + + return { + memberships, + isLoadingMemberships, + ...(topicId && { + // only return these fields if `topicId` is present, otherwise these values will be inaccurate + isTopicGated, + isActionAllowedInGatedTopic, + isRestrictedMembership, + }), + }; +}; + +export default useTopicGating; diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx index 9cf98d969d8..d061e135868 100644 --- a/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx @@ -6,15 +6,13 @@ import { parseCustomStages } from 'helpers'; import { detectURL, getThreadActionTooltipText } from 'helpers/threads'; import { useFlag } from 'hooks/useFlag'; import useJoinCommunityBanner from 'hooks/useJoinCommunityBanner'; +import useTopicGating from 'hooks/useTopicGating'; import { useCommonNavigate } from 'navigation/helpers'; import React, { useEffect, useMemo, useState } from 'react'; import { useLocation } from 'react-router-dom'; import app from 'state'; import { useGetUserEthBalanceQuery } from 'state/api/communityStake'; -import { - useFetchGroupsQuery, - useRefreshMembershipQuery, -} from 'state/api/groups'; +import { useFetchGroupsQuery } from 'state/api/groups'; import { useCreateThreadMutation } from 'state/api/threads'; import { useFetchTopicsQuery } from 'state/api/topics'; import useUserStore from 'state/ui/user'; @@ -29,7 +27,6 @@ import { MessageRow } from 'views/components/component_kit/new_designs/CWTextInp import useCommunityContests from 'views/pages/CommunityManagement/Contests/useCommunityContests'; import useAppStatus from '../../../hooks/useAppStatus'; import { ThreadKind, ThreadStage } from '../../../models/types'; -import Permissions from '../../../utils/Permissions'; import { CWText } from '../../components/component_kit/cw_text'; import { CWGatedTopicBanner } from '../component_kit/CWGatedTopicBanner'; import { CWSelectList } from '../component_kit/new_designs/CWSelectList'; @@ -66,7 +63,6 @@ export const NewThreadForm = () => { const sortedTopics = [...topics].sort((a, b) => a.name.localeCompare(b.name)); const hasTopics = sortedTopics?.length; - const isAdmin = Permissions.isCommunityAdmin() || Permissions.isSiteAdmin(); const topicsForSelector = hasTopics ? sortedTopics : []; const { @@ -110,10 +106,11 @@ export const NewThreadForm = () => { includeTopics: true, enabled: !!communityId, }); - const { data: memberships = [] } = useRefreshMembershipQuery({ + const { isRestrictedMembership } = useTopicGating({ communityId, - address: user.activeAccount?.address || '', + userAddress: user.activeAccount?.address || '', apiEnabled: !!user.activeAccount?.address && !!communityId, + topicId: threadTopic?.id || 0, }); const { mutateAsync: createThread } = useCreateThreadMutation({ @@ -139,24 +136,11 @@ export const NewThreadForm = () => { return threadTitle || getTextFromDelta(threadContentDelta).length > 0; }, [threadContentDelta, threadTitle]); - const isTopicGated = !!(memberships || []).find( - (membership) => - threadTopic?.id && membership.topics.find((t) => t.id === threadTopic.id), - ); - const isActionAllowedInGatedTopic = !!(memberships || []).find( - (membership) => - threadTopic && - threadTopic?.id && - membership.topics.find((t) => t.id === threadTopic?.id) && - membership.isAllowed, - ); const gatedGroupNames = groups .filter((group) => group.topics.find((topic) => topic.id === threadTopic?.id), ) .map((group) => group.name); - const isRestrictedMembership = - !isAdmin && isTopicGated && !isActionAllowedInGatedTopic; const handleNewThreadCreation = async () => { if (isRestrictedMembership) { diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx index c566845a28e..f8507a21666 100644 --- a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx @@ -6,15 +6,13 @@ import { parseCustomStages } from 'helpers'; import { detectURL, getThreadActionTooltipText } from 'helpers/threads'; import { useFlag } from 'hooks/useFlag'; import useJoinCommunityBanner from 'hooks/useJoinCommunityBanner'; +import useTopicGating from 'hooks/useTopicGating'; import { useCommonNavigate } from 'navigation/helpers'; import React, { useEffect, useRef, useState } from 'react'; import { useLocation } from 'react-router-dom'; import app from 'state'; import { useGetUserEthBalanceQuery } from 'state/api/communityStake'; -import { - useFetchGroupsQuery, - useRefreshMembershipQuery, -} from 'state/api/groups'; +import { useFetchGroupsQuery } from 'state/api/groups'; import { useCreateThreadMutation } from 'state/api/threads'; import { useFetchTopicsQuery } from 'state/api/topics'; import useUserStore from 'state/ui/user'; @@ -31,7 +29,6 @@ import { MessageRow } from 'views/components/component_kit/new_designs/CWTextInp import useCommunityContests from 'views/pages/CommunityManagement/Contests/useCommunityContests'; import useAppStatus from '../../../hooks/useAppStatus'; import { ThreadKind, ThreadStage } from '../../../models/types'; -import Permissions from '../../../utils/Permissions'; import { CWText } from '../../components/component_kit/cw_text'; import { CWGatedTopicBanner } from '../component_kit/CWGatedTopicBanner'; import { CWSelectList } from '../component_kit/new_designs/CWSelectList'; @@ -64,7 +61,6 @@ export const NewThreadForm = () => { const sortedTopics = [...topics].sort((a, b) => a.name.localeCompare(b.name)); const hasTopics = sortedTopics?.length; - const isAdmin = Permissions.isCommunityAdmin() || Permissions.isSiteAdmin(); const topicsForSelector = hasTopics ? sortedTopics : []; const { @@ -107,10 +103,11 @@ export const NewThreadForm = () => { includeTopics: true, enabled: !!communityId, }); - const { data: memberships = [] } = useRefreshMembershipQuery({ + const { isRestrictedMembership } = useTopicGating({ communityId, - address: user.activeAccount?.address || '', + userAddress: user.activeAccount?.address || '', apiEnabled: !!user.activeAccount?.address && !!communityId, + topicId: threadTopic?.id || 0, }); const { mutateAsync: createThread } = useCreateThreadMutation({ @@ -132,24 +129,11 @@ export const NewThreadForm = () => { const isDiscussion = threadKind === ThreadKind.Discussion; - const isTopicGated = !!(memberships || []).find( - (membership) => - threadTopic?.id && membership.topics.find((t) => t.id === threadTopic.id), - ); - const isActionAllowedInGatedTopic = !!(memberships || []).find( - (membership) => - threadTopic.id && - threadTopic?.id && - membership.topics.find((t) => t.id === threadTopic?.id) && - membership.isAllowed, - ); const gatedGroupNames = groups .filter((group) => group.topics.find((topic) => topic.id === threadTopic?.id), ) .map((group) => group.name); - const isRestrictedMembership = - !isAdmin && isTopicGated && !isActionAllowedInGatedTopic; const handleNewThreadCreation = async () => { const body = markdownEditorMethodsRef.current!.getMarkdown(); diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx b/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx index fcd1b7355de..4df91a19cf7 100644 --- a/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx +++ b/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx @@ -1,14 +1,13 @@ import { getThreadActionTooltipText } from 'helpers/threads'; import { truncate } from 'helpers/truncate'; +import useTopicGating from 'hooks/useTopicGating'; import { IThreadCollaborator } from 'models/Thread'; import moment from 'moment'; import React, { ReactNode, useMemo, useState } from 'react'; import { useNavigate } from 'react-router'; import { useSearchParams } from 'react-router-dom'; import app from 'state'; -import { useRefreshMembershipQuery } from 'state/api/groups'; import useUserStore from 'state/ui/user'; -import Permissions from 'utils/Permissions'; import { ThreadContestTagContainer } from 'views/components/ThreadContestTag'; import { isHot } from 'views/pages/discussions/helpers'; import Account from '../../../../models/Account'; @@ -125,26 +124,14 @@ export const CWContentPage = ({ const [isUpvoteDrawerOpen, setIsUpvoteDrawerOpen] = useState(false); const communityId = app.activeChainId() || ''; - const { data: memberships = [] } = useRefreshMembershipQuery({ + + const { isRestrictedMembership } = useTopicGating({ communityId, - address: user.activeAccount?.address || '', + userAddress: user.activeAccount?.address || '', apiEnabled: !!user.activeAccount?.address && !!communityId, + topicId: thread?.topic?.id || 0, }); - const isTopicGated = !!(memberships || []).find((membership) => - membership.topics.find((t) => t.id === thread?.topic?.id), - ); - - const isActionAllowedInGatedTopic = !!(memberships || []).find( - (membership) => - membership.topics.find((t) => t.id === thread?.topic?.id) && - membership.isAllowed, - ); - - const isAdmin = Permissions.isSiteAdmin() || Permissions.isCommunityAdmin(); - const isRestrictedMembership = - !isAdmin && isTopicGated && !isActionAllowedInGatedTopic; - const tabSelected = useMemo(() => { const tab = Object.fromEntries(urlQueryParams.entries())?.tab; if (!tab) { diff --git a/packages/commonwealth/client/scripts/views/components/feed.tsx b/packages/commonwealth/client/scripts/views/components/feed.tsx index 4cb11b14f71..acd1d9af37e 100644 --- a/packages/commonwealth/client/scripts/views/components/feed.tsx +++ b/packages/commonwealth/client/scripts/views/components/feed.tsx @@ -8,6 +8,7 @@ import { UserDashboardRowSkeleton } from '../pages/user_dashboard/user_dashboard import { slugify } from '@hicommonwealth/shared'; import { getThreadActionTooltipText } from 'helpers/threads'; +import useTopicGating from 'hooks/useTopicGating'; import { getProposalUrlPath } from 'identifiers'; import Thread from 'models/Thread'; import { useCommonNavigate } from 'navigation/helpers'; @@ -17,7 +18,6 @@ import { useFetchGlobalActivityQuery, useFetchUserActivityQuery, } from 'state/api/feeds'; -import { useRefreshMembershipQuery } from 'state/api/groups'; import useUserStore from 'state/ui/user'; import Permissions from 'utils/Permissions'; import { DashboardViews } from 'views/pages/user_dashboard'; @@ -50,36 +50,17 @@ const FeedThread = ({ thread }: { thread: Thread }) => { enabled: !!thread.communityId, }); - const isAdmin = - Permissions.isSiteAdmin() || Permissions.isCommunityAdmin(community); - const account = user.addresses?.find( (a) => a?.community?.id === thread?.communityId, ); - const { data: memberships = [] } = useRefreshMembershipQuery({ + const { isRestrictedMembership } = useTopicGating({ communityId: thread.communityId, - // @ts-expect-error - address: account?.address, + userAddress: account?.address || '', apiEnabled: !!account?.address && !!thread.communityId, + topicId: thread?.topic?.id || 0, }); - const isTopicGated = !!(memberships || []).find( - (membership) => - thread?.topic?.id && - membership.topics.find((t) => t.id === thread.topic.id), - ); - - const isActionAllowedInGatedTopic = !!(memberships || []).find( - (membership) => - thread?.topic?.id && - membership.topics.find((t) => t.id === thread.topic.id) && - membership.isAllowed, - ); - - const isRestrictedMembership = - !isAdmin && isTopicGated && !isActionAllowedInGatedTopic; - const disabledActionsTooltipText = getThreadActionTooltipText({ isCommunityMember: Permissions.isCommunityMember(thread.communityId), isThreadArchived: !!thread?.archivedAt, diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/Allowlist/Allowlist.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/Allowlist/Allowlist.tsx index 555cb7af480..2c90236c6e9 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/Allowlist/Allowlist.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/Allowlist/Allowlist.tsx @@ -2,10 +2,10 @@ import { DEFAULT_NAME } from '@hicommonwealth/shared'; import { MagnifyingGlass } from '@phosphor-icons/react'; import { formatAddressShort } from 'helpers'; import { APIOrderDirection } from 'helpers/constants'; +import useTopicGating from 'hooks/useTopicGating'; import React, { useMemo, useState } from 'react'; import app from 'state'; import { useGetCommunityByIdQuery } from 'state/api/communities'; -import { useRefreshMembershipQuery } from 'state/api/groups'; import useUserStore from 'state/ui/user'; import { useDebounce } from 'usehooks-ts'; import { OptionConfig, Select } from 'views/components/Select'; @@ -85,9 +85,9 @@ const Allowlist = ({ const [currentPage, setCurrentPage] = useState(1); const communityId = app.activeChainId() || ''; - const { data: memberships } = useRefreshMembershipQuery({ + const { memberships } = useTopicGating({ communityId, - address: user.activeAccount?.address || '', + userAddress: user.activeAccount?.address || '', apiEnabled: !!user.activeAccount?.address, }); diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/CommunityMembersPage.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/CommunityMembersPage.tsx index 12fc7ea6fdf..549a88a7d5a 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/CommunityMembersPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/CommunityMembersPage.tsx @@ -1,6 +1,7 @@ import { DEFAULT_NAME } from '@hicommonwealth/shared'; import { APIOrderDirection } from 'helpers/constants'; import { useBrowserAnalyticsTrack } from 'hooks/useBrowserAnalyticsTrack'; +import useTopicGating from 'hooks/useTopicGating'; import moment from 'moment'; import { useCommonNavigate } from 'navigation/helpers'; import React, { useEffect, useMemo, useState } from 'react'; @@ -12,10 +13,7 @@ import { import app from 'state'; import { useGetCommunityByIdQuery } from 'state/api/communities'; import { ApiEndpoints, queryClient } from 'state/api/config'; -import { - useFetchGroupsQuery, - useRefreshMembershipQuery, -} from 'state/api/groups'; +import { useFetchGroupsQuery } from 'state/api/groups'; import { SearchProfilesResponse } from 'state/api/profiles/searchProfiles'; import useGroupMutationBannerStore from 'state/ui/group'; import useUserStore from 'state/ui/user'; @@ -81,9 +79,9 @@ const CommunityMembersPage = () => { }); const communityId = app.activeChainId() || ''; - const { data: memberships = null } = useRefreshMembershipQuery({ + const { memberships } = useTopicGating({ communityId, - address: user?.activeAccount?.address || '', + userAddress: user?.activeAccount?.address || '', apiEnabled: !!user?.activeAccount?.address && !!communityId, }); diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx index 008bb76acdf..b260723879d 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx @@ -24,10 +24,10 @@ import { getThreadActionTooltipText } from 'helpers/threads'; import useBrowserWindow from 'hooks/useBrowserWindow'; import { useFlag } from 'hooks/useFlag'; import useManageDocumentTitle from 'hooks/useManageDocumentTitle'; +import useTopicGating from 'hooks/useTopicGating'; import 'pages/discussions/index.scss'; import { useGetCommunityByIdQuery } from 'state/api/communities'; import { useFetchCustomDomainQuery } from 'state/api/configuration'; -import { useRefreshMembershipQuery } from 'state/api/groups'; import useUserStore from 'state/ui/user'; import Permissions from 'utils/Permissions'; import { checkIsTopicInContest } from 'views/components/NewThreadFormLegacy/helpers'; @@ -91,9 +91,9 @@ const DiscussionsPage = ({ topicName }: DiscussionsPageProps) => { const user = useUserStore(); - const { data: memberships = [] } = useRefreshMembershipQuery({ + const { memberships } = useTopicGating({ communityId: communityId, - address: user.activeAccount?.address || '', + userAddress: user.activeAccount?.address || '', apiEnabled: !!user.activeAccount?.address && !!communityId, }); diff --git a/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx b/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx index 6494c8684c0..7551611599d 100644 --- a/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx +++ b/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx @@ -1,11 +1,11 @@ import { slugify } from '@hicommonwealth/shared'; import { getThreadActionTooltipText } from 'helpers/threads'; +import useTopicGating from 'hooks/useTopicGating'; import { getProposalUrlPath } from 'identifiers'; import { useCommonNavigate } from 'navigation/helpers'; import 'pages/overview/TopicSummaryRow.scss'; import React from 'react'; import app from 'state'; -import { useRefreshMembershipQuery } from 'state/api/groups'; import useUserStore from 'state/ui/user'; import Permissions from 'utils/Permissions'; import type Thread from '../../../models/Thread'; @@ -31,9 +31,10 @@ export const TopicSummaryRow = ({ const user = useUserStore(); const communityId = app.activeChainId() || ''; - const { data: memberships = [] } = useRefreshMembershipQuery({ + + const { memberships } = useTopicGating({ communityId, - address: user.activeAccount?.address || '', + userAddress: user.activeAccount?.address || '', apiEnabled: !!user.activeAccount?.address || !!communityId, }); diff --git a/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx b/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx index 5e019d3af55..b0330aabbdc 100644 --- a/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx @@ -6,6 +6,7 @@ import { filterLinks, getThreadActionTooltipText } from 'helpers/threads'; import { useBrowserAnalyticsTrack } from 'hooks/useBrowserAnalyticsTrack'; import useBrowserWindow from 'hooks/useBrowserWindow'; import useJoinCommunityBanner from 'hooks/useJoinCommunityBanner'; +import useTopicGating from 'hooks/useTopicGating'; import moment from 'moment'; import { useCommonNavigate } from 'navigation/helpers'; import 'pages/view_thread/index.scss'; @@ -14,10 +15,7 @@ import { Helmet } from 'react-helmet-async'; import app from 'state'; import { useFetchCommentsQuery } from 'state/api/comments'; import useGetViewCountByObjectIdQuery from 'state/api/general/getViewCountByObjectId'; -import { - useFetchGroupsQuery, - useRefreshMembershipQuery, -} from 'state/api/groups'; +import { useFetchGroupsQuery } from 'state/api/groups'; import { useAddThreadLinksMutation, useGetThreadPollsQuery, @@ -142,10 +140,11 @@ const ViewThreadPage = ({ identifier }: ViewThreadPageProps) => { threadId: parseInt(threadId), }); - const { data: memberships = [] } = useRefreshMembershipQuery({ + const { isRestrictedMembership } = useTopicGating({ communityId, - address: user?.activeAccount?.address || '', apiEnabled: !!user?.activeAccount?.address && !!communityId, + userAddress: user?.activeAccount?.address || '', + topicId: thread?.topic?.id || 0, }); const { data: viewCount = 0 } = useGetViewCountByObjectIdQuery({ @@ -154,19 +153,6 @@ const ViewThreadPage = ({ identifier }: ViewThreadPageProps) => { apiCallEnabled: !!thread?.id && !!communityId, }); - const isTopicGated = !!(memberships || []).find((membership) => - membership.topics.find((t) => t.id === thread?.topic?.id), - ); - - const isActionAllowedInGatedTopic = !!(memberships || []).find( - (membership) => - membership.topics.find((t) => t.id === thread?.topic?.id) && - membership.isAllowed, - ); - - const isRestrictedMembership = - !isAdmin && isTopicGated && !isActionAllowedInGatedTopic; - useEffect(() => { if (fetchCommentsError) notifyError('Failed to load comments'); }, [fetchCommentsError]); From 98e510312a86ee21610575eb3926ae41bf765503 Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Fri, 11 Oct 2024 00:49:26 +0500 Subject: [PATCH 08/27] Updated UI to show blocked action status on thread/comment actions if required conditions are not met by gated topic members --- .../client/scripts/helpers/threads.ts | 7 +++ .../client/scripts/hooks/useTopicGating.ts | 24 ++++++++ .../NewThreadFormLegacy/NewThreadForm.tsx | 35 ++++++++++- .../helpers/useNewThreadForm.ts | 4 ++ .../NewThreadFormModern/NewThreadForm.tsx | 32 +++++++++- .../helpers/useNewThreadForm.ts | 4 ++ .../CWContentPage/CWContentPage.tsx | 42 +++++++++++-- .../CWGatedTopicPermissionLevelBanner.tsx | 60 +++++++++++++++++++ .../index.ts | 3 + .../client/scripts/views/components/feed.tsx | 9 ++- .../pages/discussions/DiscussionsPage.tsx | 58 ++++++++++++++++-- .../views/pages/overview/TopicSummaryRow.tsx | 13 +++- .../pages/view_thread/ViewThreadPage.tsx | 16 ++++- 13 files changed, 288 insertions(+), 19 deletions(-) create mode 100644 packages/commonwealth/client/scripts/views/components/component_kit/CWGatedTopicPermissionLevelBanner/CWGatedTopicPermissionLevelBanner.tsx create mode 100644 packages/commonwealth/client/scripts/views/components/component_kit/CWGatedTopicPermissionLevelBanner/index.ts diff --git a/packages/commonwealth/client/scripts/helpers/threads.ts b/packages/commonwealth/client/scripts/helpers/threads.ts index c12c00c6014..374c245b5ad 100644 --- a/packages/commonwealth/client/scripts/helpers/threads.ts +++ b/packages/commonwealth/client/scripts/helpers/threads.ts @@ -1,5 +1,7 @@ +import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; import { re_weburl } from 'lib/url-validation'; import { Link, LinkSource } from 'models/Thread'; +import { TOPIC_PERMISSIONS } from '../views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/constants'; export function detectURL(str: string) { if (str.slice(0, 4) !== 'http') str = `http://${str}`; // no https required because this is only used for regex match @@ -55,11 +57,13 @@ export const getThreadActionTooltipText = ({ isThreadArchived = false, isThreadLocked = false, isThreadTopicGated = false, + threadTopicInteractionRestriction, }: { isCommunityMember?: boolean; isThreadArchived?: boolean; isThreadLocked?: boolean; isThreadTopicGated?: boolean; + threadTopicInteractionRestriction?: GroupTopicPermissionEnum; }): GetThreadActionTooltipTextResponse => { if (!isCommunityMember) { return getActionTooltipForNonCommunityMember; @@ -67,5 +71,8 @@ export const getThreadActionTooltipText = ({ if (isThreadArchived) return 'Thread is archived'; if (isThreadLocked) return 'Thread is locked'; if (isThreadTopicGated) return 'Topic is gated'; + if (threadTopicInteractionRestriction) { + return `Topic members are only allowed to ${TOPIC_PERMISSIONS[threadTopicInteractionRestriction]}`; + } return ''; }; diff --git a/packages/commonwealth/client/scripts/hooks/useTopicGating.ts b/packages/commonwealth/client/scripts/hooks/useTopicGating.ts index 78910c052d7..fa94050c3e1 100644 --- a/packages/commonwealth/client/scripts/hooks/useTopicGating.ts +++ b/packages/commonwealth/client/scripts/hooks/useTopicGating.ts @@ -1,6 +1,9 @@ +import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; import { useRefreshMembershipQuery } from 'state/api/groups'; import Permissions from '../utils/Permissions'; +type TopicPermission = { id: number; permission: GroupTopicPermissionEnum }; + type IuseTopicGating = { communityId: string; apiEnabled: boolean; @@ -21,6 +24,21 @@ const useTopicGating = ({ apiEnabled, }); + const topicPermissions = memberships + .map((m) => m.topics) + .flat() + .reduce((acc, current) => { + const existing = acc.find((item) => item.id === current.id); + if (!existing) { + acc.push(current); + } else if (current.permission.length > existing.permission.length) { + // Replace with the current item if it has a longer permission string + const index = acc.indexOf(existing); + acc[index] = current; + } + return acc; + }, []); + const isTopicGated = !!(memberships || []).find((membership) => membership.topics.find((t) => t.id === topicId), ); @@ -35,14 +53,20 @@ const useTopicGating = ({ const isRestrictedMembership = !isAdmin && isTopicGated && !isActionAllowedInGatedTopic; + const foundTopicPermissions = topicPermissions.find( + (tp) => tp.id === topicId, + ); + return { memberships, isLoadingMemberships, + topicPermissions, ...(topicId && { // only return these fields if `topicId` is present, otherwise these values will be inaccurate isTopicGated, isActionAllowedInGatedTopic, isRestrictedMembership, + foundTopicPermissions, }), }; }; diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx index d061e135868..3a1fe756e90 100644 --- a/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx @@ -1,3 +1,4 @@ +import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; import { buildCreateThreadInput } from 'client/scripts/state/api/threads/createThread'; import { useAuthModalStore } from 'client/scripts/state/ui/modals'; import { notifyError } from 'controllers/app/notifications'; @@ -29,6 +30,7 @@ import useAppStatus from '../../../hooks/useAppStatus'; import { ThreadKind, ThreadStage } from '../../../models/types'; import { CWText } from '../../components/component_kit/cw_text'; import { CWGatedTopicBanner } from '../component_kit/CWGatedTopicBanner'; +import { CWGatedTopicPermissionLevelBanner } from '../component_kit/CWGatedTopicPermissionLevelBanner'; import { CWSelectList } from '../component_kit/new_designs/CWSelectList'; import { ReactQuillEditor } from '../react_quill_editor'; import { @@ -80,6 +82,8 @@ export const NewThreadForm = () => { clearDraft, canShowGatingBanner, setCanShowGatingBanner, + canShowTopicPermissionBanner, + setCanShowTopicPermissionBanner, } = useNewThreadForm(communityId, topicsForSelector); const hasTopicOngoingContest = threadTopic?.activeContestManagers?.length > 0; @@ -106,7 +110,7 @@ export const NewThreadForm = () => { includeTopics: true, enabled: !!communityId, }); - const { isRestrictedMembership } = useTopicGating({ + const { isRestrictedMembership, foundTopicPermissions } = useTopicGating({ communityId, userAddress: user.activeAccount?.address || '', apiEnabled: !!user.activeAccount?.address && !!communityId, @@ -215,6 +219,12 @@ export const NewThreadForm = () => { const disabledActionsTooltipText = getThreadActionTooltipText({ isCommunityMember: !!user.activeAccount, isThreadTopicGated: isRestrictedMembership, + threadTopicInteractionRestriction: + !foundTopicPermissions?.permission?.includes( + GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, + ) + ? foundTopicPermissions?.permission + : undefined, }); const contestThreadBannerVisible = @@ -297,6 +307,7 @@ export const NewThreadForm = () => { } onChange={(topic) => { setCanShowGatingBanner(true); + setCanShowTopicPermissionBanner(true); setThreadTopic( // @ts-expect-error topicsForSelector.find((t) => `${t.id}` === topic.value), @@ -333,7 +344,11 @@ export const NewThreadForm = () => { { !user.activeAccount || isDisabledBecauseOfContestsConsent || walletBalanceError || - contestTopicError + contestTopicError || + !!disabledActionsTooltipText } // eslint-disable-next-line @typescript-eslint/no-misused-promises onClick={handleNewThreadCreation} @@ -398,6 +414,19 @@ export const NewThreadForm = () => { />
)} + + {canShowTopicPermissionBanner && + foundTopicPermissions && + !foundTopicPermissions?.permission?.includes( + GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, + ) && ( + setCanShowTopicPermissionBanner(false)} + /> + )} diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/helpers/useNewThreadForm.ts b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/helpers/useNewThreadForm.ts index c1784d23d2c..5911f7035b4 100644 --- a/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/helpers/useNewThreadForm.ts +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/helpers/useNewThreadForm.ts @@ -21,6 +21,8 @@ const useNewThreadForm = (communityId: string, topicsForSelector: Topic[]) => { `new-thread-${communityId}-info`, ); const [canShowGatingBanner, setCanShowGatingBanner] = useState(true); + const [canShowTopicPermissionBanner, setCanShowTopicPermissionBanner] = + useState(true); // get restored draft on init const restoredDraft: NewThreadDraft | null = useMemo(() => { @@ -116,6 +118,8 @@ const useNewThreadForm = (communityId: string, topicsForSelector: Topic[]) => { clearDraft, canShowGatingBanner, setCanShowGatingBanner, + canShowTopicPermissionBanner, + setCanShowTopicPermissionBanner, }; }; diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx index f8507a21666..334b3fcf230 100644 --- a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx @@ -1,3 +1,4 @@ +import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; import { buildCreateThreadInput } from 'client/scripts/state/api/threads/createThread'; import { useAuthModalStore } from 'client/scripts/state/ui/modals'; import { notifyError } from 'controllers/app/notifications'; @@ -31,6 +32,7 @@ import useAppStatus from '../../../hooks/useAppStatus'; import { ThreadKind, ThreadStage } from '../../../models/types'; import { CWText } from '../../components/component_kit/cw_text'; import { CWGatedTopicBanner } from '../component_kit/CWGatedTopicBanner'; +import { CWGatedTopicPermissionLevelBanner } from '../component_kit/CWGatedTopicPermissionLevelBanner'; import { CWSelectList } from '../component_kit/new_designs/CWSelectList'; import ContestThreadBanner from './ContestThreadBanner'; import ContestTopicBanner from './ContestTopicBanner'; @@ -77,6 +79,8 @@ export const NewThreadForm = () => { clearDraft, canShowGatingBanner, setCanShowGatingBanner, + canShowTopicPermissionBanner, + setCanShowTopicPermissionBanner, } = useNewThreadForm(communityId, topicsForSelector); const hasTopicOngoingContest = threadTopic?.activeContestManagers?.length > 0; @@ -103,7 +107,7 @@ export const NewThreadForm = () => { includeTopics: true, enabled: !!communityId, }); - const { isRestrictedMembership } = useTopicGating({ + const { isRestrictedMembership, foundTopicPermissions } = useTopicGating({ communityId, userAddress: user.activeAccount?.address || '', apiEnabled: !!user.activeAccount?.address && !!communityId, @@ -199,6 +203,12 @@ export const NewThreadForm = () => { const disabledActionsTooltipText = getThreadActionTooltipText({ isCommunityMember: !!user.activeAccount, isThreadTopicGated: isRestrictedMembership, + threadTopicInteractionRestriction: + !foundTopicPermissions?.permission?.includes( + GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, + ) + ? foundTopicPermissions?.permission + : undefined, }); const contestThreadBannerVisible = @@ -319,7 +329,11 @@ export const NewThreadForm = () => { (markdownEditorMethodsRef.current = methods) } onChange={(markdown) => setEditorText(markdown)} - disabled={isRestrictedMembership || !user.activeAccount} + disabled={ + isRestrictedMembership || + !!disabledActionsTooltipText || + !user.activeAccount + } tooltip={ typeof disabledActionsTooltipText === 'function' ? disabledActionsTooltipText?.('submit') @@ -332,6 +346,7 @@ export const NewThreadForm = () => { disabled={ isDisabled || !user.activeAccount || + !!disabledActionsTooltipText || isDisabledBecauseOfContestsConsent || walletBalanceError || contestTopicError @@ -373,6 +388,19 @@ export const NewThreadForm = () => { /> )} + + {canShowTopicPermissionBanner && + foundTopicPermissions && + !foundTopicPermissions?.permission?.includes( + GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, + ) && ( + setCanShowTopicPermissionBanner(false)} + /> + )} diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/helpers/useNewThreadForm.ts b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/helpers/useNewThreadForm.ts index 7bef182947f..8931bfd4d10 100644 --- a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/helpers/useNewThreadForm.ts +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/helpers/useNewThreadForm.ts @@ -20,6 +20,8 @@ const useNewThreadForm = (communityId: string, topicsForSelector: Topic[]) => { { keyVersion: 'v3' }, ); const [canShowGatingBanner, setCanShowGatingBanner] = useState(true); + const [canShowTopicPermissionBanner, setCanShowTopicPermissionBanner] = + useState(true); // get restored draft on init const restoredDraft: NewThreadDraft | null = useMemo(() => { @@ -113,6 +115,8 @@ const useNewThreadForm = (communityId: string, topicsForSelector: Topic[]) => { clearDraft, canShowGatingBanner, setCanShowGatingBanner, + canShowTopicPermissionBanner, + setCanShowTopicPermissionBanner, }; }; diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx b/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx index 4df91a19cf7..45bd2e76b73 100644 --- a/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx +++ b/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx @@ -1,3 +1,4 @@ +import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; import { getThreadActionTooltipText } from 'helpers/threads'; import { truncate } from 'helpers/truncate'; import useTopicGating from 'hooks/useTopicGating'; @@ -125,7 +126,7 @@ export const CWContentPage = ({ const communityId = app.activeChainId() || ''; - const { isRestrictedMembership } = useTopicGating({ + const { isRestrictedMembership, foundTopicPermissions } = useTopicGating({ communityId, userAddress: user.activeAccount?.address || '', apiEnabled: !!user.activeAccount?.address && !!communityId, @@ -209,6 +210,27 @@ export const CWContentPage = ({ isThreadTopicGated: isRestrictedMembership, }); + const disabledReactPermissionTooltipText = getThreadActionTooltipText({ + isCommunityMember: !!user.activeAccount, + threadTopicInteractionRestriction: + !foundTopicPermissions?.permission?.includes( + GroupTopicPermissionEnum.UPVOTE, + ) + ? foundTopicPermissions?.permission + : undefined, + }); + + const disabledCommentPermissionTooltipText = getThreadActionTooltipText({ + isCommunityMember: !!user.activeAccount, + threadTopicInteractionRestriction: + !foundTopicPermissions?.permission?.includes( + GroupTopicPermissionEnum.UPVOTE_AND_COMMENT, + ) + ? foundTopicPermissions?.permission + : undefined, + }); + console.log('disabledActionsTooltipText => ', disabledActionsTooltipText); + const mainBody = (
@@ -245,10 +267,22 @@ export const CWContentPage = ({ onEditStart={onEditStart} canUpdateThread={canUpdateThread} hasPendingEdits={hasPendingEdits} - canReact={!disabledActionsTooltipText} - canComment={!disabledActionsTooltipText} + canReact={ + disabledReactPermissionTooltipText + ? !disabledReactPermissionTooltipText + : !disabledActionsTooltipText + } + canComment={ + disabledCommentPermissionTooltipText + ? !disabledCommentPermissionTooltipText + : !disabledActionsTooltipText + } onProposalStageChange={onProposalStageChange} - disabledActionsTooltipText={disabledActionsTooltipText} + disabledActionsTooltipText={ + disabledReactPermissionTooltipText || + disabledCommentPermissionTooltipText || + disabledActionsTooltipText + } onSnapshotProposalFromThread={onSnapshotProposalFromThread} setIsUpvoteDrawerOpen={setIsUpvoteDrawerOpen} shareEndpoint={`${window.location.origin}${window.location.pathname}`} diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/CWGatedTopicPermissionLevelBanner/CWGatedTopicPermissionLevelBanner.tsx b/packages/commonwealth/client/scripts/views/components/component_kit/CWGatedTopicPermissionLevelBanner/CWGatedTopicPermissionLevelBanner.tsx new file mode 100644 index 00000000000..9ecdce4349c --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/component_kit/CWGatedTopicPermissionLevelBanner/CWGatedTopicPermissionLevelBanner.tsx @@ -0,0 +1,60 @@ +import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; +import { useBrowserAnalyticsTrack } from 'hooks/useBrowserAnalyticsTrack'; +import { useCommonNavigate } from 'navigation/helpers'; +import React from 'react'; +import { TOPIC_PERMISSIONS } from 'views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/constants'; +import { + MixpanelClickthroughEvent, + MixpanelClickthroughPayload, +} from '../../../../../../shared/analytics/types'; +import useAppStatus from '../../../../hooks/useAppStatus'; +import CWBanner from '../new_designs/CWBanner'; + +interface CWGatedTopicPermissionLevelBannerProps { + onClose: () => void; + topicPermission: GroupTopicPermissionEnum; +} + +const CWGatedTopicPermissionLevelBanner = ({ + onClose = () => {}, + topicPermission, +}: CWGatedTopicPermissionLevelBannerProps) => { + const navigate = useCommonNavigate(); + + const { isAddedToHomeScreen } = useAppStatus(); + + const { trackAnalytics } = + useBrowserAnalyticsTrack({ + onAction: true, + }); + + return ( + { + trackAnalytics({ + event: MixpanelClickthroughEvent.VIEW_THREAD_TO_MEMBERS_PAGE, + isPWA: isAddedToHomeScreen, + }); + navigate('/members?tab=groups'); + }, + }, + { + label: 'Learn more about gating', + onClick: () => + window.open( + `https://blog.commonwealth.im/introducing-common-groups/`, + ), + }, + ]} + onClose={onClose} + /> + ); +}; + +export default CWGatedTopicPermissionLevelBanner; diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/CWGatedTopicPermissionLevelBanner/index.ts b/packages/commonwealth/client/scripts/views/components/component_kit/CWGatedTopicPermissionLevelBanner/index.ts new file mode 100644 index 00000000000..d3b7f6d1cbb --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/component_kit/CWGatedTopicPermissionLevelBanner/index.ts @@ -0,0 +1,3 @@ +import CWGatedTopicPermissionLevelBanner from './CWGatedTopicPermissionLevelBanner'; + +export { CWGatedTopicPermissionLevelBanner }; diff --git a/packages/commonwealth/client/scripts/views/components/feed.tsx b/packages/commonwealth/client/scripts/views/components/feed.tsx index acd1d9af37e..e047e63a98d 100644 --- a/packages/commonwealth/client/scripts/views/components/feed.tsx +++ b/packages/commonwealth/client/scripts/views/components/feed.tsx @@ -6,6 +6,7 @@ import 'components/feed.scss'; import { PageNotFound } from '../pages/404'; import { UserDashboardRowSkeleton } from '../pages/user_dashboard/user_dashboard_row'; +import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; import { slugify } from '@hicommonwealth/shared'; import { getThreadActionTooltipText } from 'helpers/threads'; import useTopicGating from 'hooks/useTopicGating'; @@ -54,7 +55,7 @@ const FeedThread = ({ thread }: { thread: Thread }) => { (a) => a?.community?.id === thread?.communityId, ); - const { isRestrictedMembership } = useTopicGating({ + const { isRestrictedMembership, foundTopicPermissions } = useTopicGating({ communityId: thread.communityId, userAddress: account?.address || '', apiEnabled: !!account?.address && !!thread.communityId, @@ -66,6 +67,12 @@ const FeedThread = ({ thread }: { thread: Thread }) => { isThreadArchived: !!thread?.archivedAt, isThreadLocked: !!thread?.lockedAt, isThreadTopicGated: isRestrictedMembership, + threadTopicInteractionRestriction: + !foundTopicPermissions?.permission?.includes( + GroupTopicPermissionEnum.UPVOTE_AND_COMMENT, // on this page we only show comment option + ) + ? foundTopicPermissions?.permission + : undefined, }); // edge case for deleted communities with orphaned posts diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx index b260723879d..598e0018772 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx @@ -1,4 +1,7 @@ -import { TopicWeightedVoting } from '@hicommonwealth/schemas'; +import { + GroupTopicPermissionEnum, + TopicWeightedVoting, +} from '@hicommonwealth/schemas'; import { getProposalUrlPath } from 'identifiers'; import { getScopePrefix, useCommonNavigate } from 'navigation/helpers'; import React, { useEffect, useRef, useState } from 'react'; @@ -91,7 +94,7 @@ const DiscussionsPage = ({ topicName }: DiscussionsPageProps) => { const user = useUserStore(); - const { memberships } = useTopicGating({ + const { memberships, topicPermissions } = useTopicGating({ communityId: communityId, userAddress: user.activeAccount?.address || '', apiEnabled: !!user.activeAccount?.address && !!communityId, @@ -222,13 +225,46 @@ const DiscussionsPage = ({ topicName }: DiscussionsPageProps) => { const isRestrictedMembership = !isAdmin && isTopicGated && !isActionAllowedInGatedTopic; + const foundTopicPermissions = topicPermissions.find( + (tp) => tp.id === thread.topic.id, + ); + const disabledActionsTooltipText = getThreadActionTooltipText({ isCommunityMember: !!user.activeAccount, isThreadArchived: !!thread?.archivedAt, isThreadLocked: !!thread?.lockedAt, isThreadTopicGated: isRestrictedMembership, + threadTopicInteractionRestriction: + !foundTopicPermissions?.permission?.includes( + GroupTopicPermissionEnum.UPVOTE, + ) + ? foundTopicPermissions?.permission + : undefined, }); + const disabledReactPermissionTooltipText = getThreadActionTooltipText( + { + isCommunityMember: !!user.activeAccount, + threadTopicInteractionRestriction: + !foundTopicPermissions?.permission?.includes( + GroupTopicPermissionEnum.UPVOTE, + ) + ? foundTopicPermissions?.permission + : undefined, + }, + ); + + const disabledCommentPermissionTooltipText = + getThreadActionTooltipText({ + isCommunityMember: !!user.activeAccount, + threadTopicInteractionRestriction: + !foundTopicPermissions?.permission?.includes( + GroupTopicPermissionEnum.UPVOTE_AND_COMMENT, + ) + ? foundTopicPermissions?.permission + : undefined, + }); + const isThreadTopicInContest = checkIsTopicInContest( contestsData, thread?.topic?.id, @@ -238,8 +274,16 @@ const DiscussionsPage = ({ topicName }: DiscussionsPageProps) => { navigate(`${discussionLink}`)} onStageTagClick={() => { navigate(`/discussions?stage=${thread.stage}`); @@ -254,7 +298,11 @@ const DiscussionsPage = ({ topicName }: DiscussionsPageProps) => { onCommentBtnClick={() => navigate(`${discussionLink}?focusComments=true`) } - disabledActionsTooltipText={disabledActionsTooltipText} + disabledActionsTooltipText={ + disabledCommentPermissionTooltipText || + disabledReactPermissionTooltipText || + disabledActionsTooltipText + } hideRecentComments editingDisabled={isThreadTopicInContest} /> diff --git a/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx b/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx index 7551611599d..815ee7f2508 100644 --- a/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx +++ b/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx @@ -1,3 +1,4 @@ +import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; import { slugify } from '@hicommonwealth/shared'; import { getThreadActionTooltipText } from 'helpers/threads'; import useTopicGating from 'hooks/useTopicGating'; @@ -32,7 +33,7 @@ export const TopicSummaryRow = ({ const communityId = app.activeChainId() || ''; - const { memberships } = useTopicGating({ + const { memberships, topicPermissions } = useTopicGating({ communityId, userAddress: user.activeAccount?.address || '', apiEnabled: !!user.activeAccount?.address || !!communityId, @@ -106,6 +107,10 @@ export const TopicSummaryRow = ({ const isRestrictedMembership = !isAdmin && isTopicGated && !isActionAllowedInGatedTopic; + const foundTopicPermissions = topicPermissions.find( + (tp) => tp.id === thread.topic.id, + ); + const disabledActionsTooltipText = getThreadActionTooltipText({ isCommunityMember: Permissions.isCommunityMember( thread.communityId, @@ -113,6 +118,12 @@ export const TopicSummaryRow = ({ isThreadArchived: !!thread?.archivedAt, isThreadLocked: !!thread?.lockedAt, isThreadTopicGated: isRestrictedMembership, + threadTopicInteractionRestriction: + !foundTopicPermissions?.permission?.includes( + GroupTopicPermissionEnum.UPVOTE_AND_COMMENT, // on this page we only show comment option + ) + ? foundTopicPermissions?.permission + : undefined, }); return ( diff --git a/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx b/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx index b0330aabbdc..e36cf0f454d 100644 --- a/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx @@ -1,3 +1,4 @@ +import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; import { ContentType, getThreadUrl } from '@hicommonwealth/shared'; import { notifyError } from 'controllers/app/notifications'; import { extractDomain, isDefaultStage } from 'helpers'; @@ -140,7 +141,7 @@ const ViewThreadPage = ({ identifier }: ViewThreadPageProps) => { threadId: parseInt(threadId), }); - const { isRestrictedMembership } = useTopicGating({ + const { isRestrictedMembership, foundTopicPermissions } = useTopicGating({ communityId, apiEnabled: !!user?.activeAccount?.address && !!communityId, userAddress: user?.activeAccount?.address || '', @@ -256,8 +257,6 @@ const ViewThreadPage = ({ identifier }: ViewThreadPageProps) => { // @ts-expect-error const hasWebLinks = thread.links.find((x) => x.source === 'web'); - const canComment = !!user.activeAccount && !isRestrictedMembership; - const handleNewSnapshotChange = async ({ id, snapshot_title, @@ -332,8 +331,19 @@ const ViewThreadPage = ({ identifier }: ViewThreadPageProps) => { isThreadArchived: !!thread?.archivedAt, isThreadLocked: !!thread?.lockedAt, isThreadTopicGated: isRestrictedMembership, + threadTopicInteractionRestriction: + !foundTopicPermissions?.permission?.includes( + GroupTopicPermissionEnum.UPVOTE_AND_COMMENT, + ) + ? foundTopicPermissions?.permission + : undefined, }); + const canComment = + !!user.activeAccount && + !isRestrictedMembership && + !disabledActionsTooltipText; + const getMetaDescription = (meta: string) => { try { const parsedMeta = JSON.parse(meta); From a4f7e7a99204e385da31fd0190415b3d22e0446d Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Fri, 11 Oct 2024 10:06:08 +0500 Subject: [PATCH 09/27] Added migration to allows existing groups/topics to have full topic level permissioning --- ...igrate-existing-group-topic-permissions.js | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 packages/commonwealth/server/migrations/20241010195545-migrate-existing-group-topic-permissions.js diff --git a/packages/commonwealth/server/migrations/20241010195545-migrate-existing-group-topic-permissions.js b/packages/commonwealth/server/migrations/20241010195545-migrate-existing-group-topic-permissions.js new file mode 100644 index 00000000000..3bb8ecd265b --- /dev/null +++ b/packages/commonwealth/server/migrations/20241010195545-migrate-existing-group-topic-permissions.js @@ -0,0 +1,30 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async (t) => { + await queryInterface.sequelize.query( + ` + INSERT INTO "GroupTopicPermissions" + (group_id, topic_id, allowed_actions, created_at, updated_at) + SELECT + unnest(t.group_ids) AS group_id, + t.id AS topic_id, + 'UPVOTE_AND_COMMENT_POST' AS allowed_actions, + NOW() AS created_at, + NOW() AS updated_at + FROM "Topics" t + WHERE + ARRAY_LENGTH(t.group_ids, 1) IS NOT NULL + AND ARRAY_LENGTH(t.group_ids, 1) > 0; + `, + { transaction: t }, + ); + }); + }, + + async down(queryInterface, Sequelize) { + // not really-possible/recommended to rollback since "GroupTopicPermissions" might have more entries + }, +}; From 12ff29cf594a854426f6221773ead968fdceb26a Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Fri, 11 Oct 2024 10:14:25 +0500 Subject: [PATCH 10/27] Fix migration --- .../controllers/server_groups_methods/get_groups.ts | 12 +++++------- .../server_groups_methods/refresh_membership.ts | 11 ++++------- ...95545-migrate-existing-group-topic-permissions.js | 2 +- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/packages/commonwealth/server/controllers/server_groups_methods/get_groups.ts b/packages/commonwealth/server/controllers/server_groups_methods/get_groups.ts index 0a56395bbd1..fcb5003a829 100644 --- a/packages/commonwealth/server/controllers/server_groups_methods/get_groups.ts +++ b/packages/commonwealth/server/controllers/server_groups_methods/get_groups.ts @@ -94,13 +94,11 @@ export async function __getGroups( .filter((t) => t.group_ids!.includes(group.id!)) .map((t) => { const temp: TopicAttributesWithPermission = { ...t.toJSON() }; - temp.permission = - ( - (group as GroupInstanceWithTopicPermissions) - .GroupTopicPermissions || [] - ).find((gtp) => gtp.topic_id === t.id)?.allowed_actions || - // TODO: this fallback should be via a migration for existing communities - GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST; + temp.permission = ( + (group as GroupInstanceWithTopicPermissions) + .GroupTopicPermissions || [] + ).find((gtp) => gtp.topic_id === t.id) + ?.allowed_actions as GroupTopicPermissionEnum; return temp; }), })); diff --git a/packages/commonwealth/server/controllers/server_groups_methods/refresh_membership.ts b/packages/commonwealth/server/controllers/server_groups_methods/refresh_membership.ts index 2c33f527a93..e7dc20f43ba 100644 --- a/packages/commonwealth/server/controllers/server_groups_methods/refresh_membership.ts +++ b/packages/commonwealth/server/controllers/server_groups_methods/refresh_membership.ts @@ -75,13 +75,10 @@ export async function __refreshMembership( .filter((t) => t.group_ids!.includes(membership.group_id)) .map((t) => ({ id: t.id, - permission: - (groups as GroupInstanceWithTopicPermissions[]) - .find((g) => g.id === membership.group_id) - ?.GroupTopicPermissions?.find((gtp) => gtp.topic_id === t.id) - ?.allowed_actions || - // TODO: this fallback should be via a migration for existing communities - GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, + permission: (groups as GroupInstanceWithTopicPermissions[]) + .find((g) => g.id === membership.group_id) + ?.GroupTopicPermissions?.find((gtp) => gtp.topic_id === t.id) + ?.allowed_actions as GroupTopicPermissionEnum, })), allowed: !membership.reject_reason, rejectReason: membership.reject_reason, diff --git a/packages/commonwealth/server/migrations/20241010195545-migrate-existing-group-topic-permissions.js b/packages/commonwealth/server/migrations/20241010195545-migrate-existing-group-topic-permissions.js index 3bb8ecd265b..9383d7783e2 100644 --- a/packages/commonwealth/server/migrations/20241010195545-migrate-existing-group-topic-permissions.js +++ b/packages/commonwealth/server/migrations/20241010195545-migrate-existing-group-topic-permissions.js @@ -11,7 +11,7 @@ module.exports = { SELECT unnest(t.group_ids) AS group_id, t.id AS topic_id, - 'UPVOTE_AND_COMMENT_POST' AS allowed_actions, + 'UPVOTE_AND_COMMENT_AND_POST' AS allowed_actions, NOW() AS created_at, NOW() AS updated_at FROM "Topics" t From 74c20f3c01c463e39ddbd2783a81cb8361142c90 Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Fri, 11 Oct 2024 10:17:46 +0500 Subject: [PATCH 11/27] Fix group card topics styling --- .../Members/GroupsSection/GroupCard/GroupCard.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.scss b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.scss index e38570fc430..aaa2dce4216 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.scss +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.scss @@ -52,11 +52,16 @@ padding: 8px 0; width: 100%; display: grid; + align-items: center; grid-template-columns: 1fr 1fr; .Tag { height: auto; + .Text { + height: fit-content; + } + @include extraSmall { .Text { word-wrap: break-word; From ba6be003d6d7884dd0aae522326f36a0bbda26f9 Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Fri, 11 Oct 2024 10:31:32 +0500 Subject: [PATCH 12/27] Fix CI --- .../community/community-lifecycle.spec.ts | 25 ++++++++++++++++--- .../CWContentPage/CWContentPage.tsx | 1 - 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/libs/model/test/community/community-lifecycle.spec.ts b/libs/model/test/community/community-lifecycle.spec.ts index 9adb0f9f583..358603b9cf3 100644 --- a/libs/model/test/community/community-lifecycle.spec.ts +++ b/libs/model/test/community/community-lifecycle.spec.ts @@ -7,7 +7,10 @@ import { dispose, query, } from '@hicommonwealth/core'; -import { TopicWeightedVoting } from '@hicommonwealth/schemas'; +import { + GroupTopicPermissionEnum, + TopicWeightedVoting, +} from '@hicommonwealth/schemas'; import { ChainBase, ChainType } from '@hicommonwealth/shared'; import { Chance } from 'chance'; import { CreateTopic } from 'model/src/community/CreateTopic.command'; @@ -38,7 +41,10 @@ import { seed } from '../../src/tester'; const chance = Chance(); -function buildCreateGroupPayload(community_id: string, topics: number[] = []) { +function buildCreateGroupPayload( + community_id: string, + topics: { id: number; permission: GroupTopicPermissionEnum }[] = [], +) { return { community_id, metadata: { @@ -355,7 +361,20 @@ describe('Community lifecycle', () => { await expect( command(CreateGroup(), { actor: ethAdminActor, - payload: buildCreateGroupPayload(community.id, [1, 2, 3]), + payload: buildCreateGroupPayload(community.id, [ + { + id: 1, + permission: GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, + }, + { + id: 2, + permission: GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, + }, + { + id: 3, + permission: GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, + }, + ]), }), ).rejects.toThrow(CreateGroupErrors.InvalidTopics); }); diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx b/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx index 45bd2e76b73..4feb33f2e25 100644 --- a/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx +++ b/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx @@ -229,7 +229,6 @@ export const CWContentPage = ({ ? foundTopicPermissions?.permission : undefined, }); - console.log('disabledActionsTooltipText => ', disabledActionsTooltipText); const mainBody = (
From e8e07799c4dea85790b90c92a2efd76927e0611b Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Fri, 11 Oct 2024 10:40:57 +0500 Subject: [PATCH 13/27] Fix lint --- .../Members/GroupsSection/GroupCard/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/types.ts b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/types.ts index 5066de8776c..954b4c038d5 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/types.ts +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/types.ts @@ -19,6 +19,6 @@ export type GroupCardProps = { allowLists?: string[]; topics: { id: number; name: string; permission?: TopicPermissions }[]; canEdit?: boolean; - onEditClick?: () => any; + onEditClick?: () => void; profiles?: Map; }; From 77840a1bb57c0cbec343f1934755657ed231890e Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Fri, 11 Oct 2024 13:49:48 +0500 Subject: [PATCH 14/27] Allow community admins to bypass topic gating restrictions --- .../components/NewThreadFormLegacy/NewThreadForm.tsx | 9 +++++++-- .../components/NewThreadFormModern/NewThreadForm.tsx | 9 +++++++-- .../component_kit/CWContentPage/CWContentPage.tsx | 5 +++++ .../client/scripts/views/components/feed.tsx | 8 ++++++++ .../scripts/views/pages/discussions/DiscussionsPage.tsx | 3 +++ .../scripts/views/pages/overview/TopicSummaryRow.tsx | 1 + .../scripts/views/pages/view_thread/ViewThreadPage.tsx | 1 + 7 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx index 3a1fe756e90..b638cfd6f06 100644 --- a/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx @@ -1,6 +1,4 @@ import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; -import { buildCreateThreadInput } from 'client/scripts/state/api/threads/createThread'; -import { useAuthModalStore } from 'client/scripts/state/ui/modals'; import { notifyError } from 'controllers/app/notifications'; import { SessionKeyError } from 'controllers/server/sessions'; import { parseCustomStages } from 'helpers'; @@ -15,8 +13,11 @@ import app from 'state'; import { useGetUserEthBalanceQuery } from 'state/api/communityStake'; import { useFetchGroupsQuery } from 'state/api/groups'; import { useCreateThreadMutation } from 'state/api/threads'; +import { buildCreateThreadInput } from 'state/api/threads/createThread'; import { useFetchTopicsQuery } from 'state/api/topics'; +import { useAuthModalStore } from 'state/ui/modals'; import useUserStore from 'state/ui/user'; +import Permissions from 'utils/Permissions'; import JoinCommunityBanner from 'views/components/JoinCommunityBanner'; import CustomTopicOption from 'views/components/NewThreadFormLegacy/CustomTopicOption'; import useJoinCommunity from 'views/components/SublayoutHeader/useJoinCommunity'; @@ -117,6 +118,8 @@ export const NewThreadForm = () => { topicId: threadTopic?.id || 0, }); + const isAdmin = Permissions.isSiteAdmin() || Permissions.isCommunityAdmin(); + const { mutateAsync: createThread } = useCreateThreadMutation({ communityId, }); @@ -220,6 +223,7 @@ export const NewThreadForm = () => { isCommunityMember: !!user.activeAccount, isThreadTopicGated: isRestrictedMembership, threadTopicInteractionRestriction: + !isAdmin && !foundTopicPermissions?.permission?.includes( GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, ) @@ -417,6 +421,7 @@ export const NewThreadForm = () => { {canShowTopicPermissionBanner && foundTopicPermissions && + !isAdmin && !foundTopicPermissions?.permission?.includes( GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, ) && ( diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx index 334b3fcf230..9e2a5c0d793 100644 --- a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx @@ -1,6 +1,4 @@ import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; -import { buildCreateThreadInput } from 'client/scripts/state/api/threads/createThread'; -import { useAuthModalStore } from 'client/scripts/state/ui/modals'; import { notifyError } from 'controllers/app/notifications'; import { SessionKeyError } from 'controllers/server/sessions'; import { parseCustomStages } from 'helpers'; @@ -15,8 +13,11 @@ import app from 'state'; import { useGetUserEthBalanceQuery } from 'state/api/communityStake'; import { useFetchGroupsQuery } from 'state/api/groups'; import { useCreateThreadMutation } from 'state/api/threads'; +import { buildCreateThreadInput } from 'state/api/threads/createThread'; import { useFetchTopicsQuery } from 'state/api/topics'; +import { useAuthModalStore } from 'state/ui/modals'; import useUserStore from 'state/ui/user'; +import Permissions from 'utils/Permissions'; import JoinCommunityBanner from 'views/components/JoinCommunityBanner'; import MarkdownEditor from 'views/components/MarkdownEditor'; import { MarkdownSubmitButton } from 'views/components/MarkdownEditor/MarkdownSubmitButton'; @@ -114,6 +115,8 @@ export const NewThreadForm = () => { topicId: threadTopic?.id || 0, }); + const isAdmin = Permissions.isSiteAdmin() || Permissions.isCommunityAdmin(); + const { mutateAsync: createThread } = useCreateThreadMutation({ communityId, }); @@ -204,6 +207,7 @@ export const NewThreadForm = () => { isCommunityMember: !!user.activeAccount, isThreadTopicGated: isRestrictedMembership, threadTopicInteractionRestriction: + !isAdmin && !foundTopicPermissions?.permission?.includes( GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, ) @@ -391,6 +395,7 @@ export const NewThreadForm = () => { {canShowTopicPermissionBanner && foundTopicPermissions && + !isAdmin && !foundTopicPermissions?.permission?.includes( GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, ) && ( diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx b/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx index 4feb33f2e25..df0226e0738 100644 --- a/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx +++ b/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx @@ -9,6 +9,7 @@ import { useNavigate } from 'react-router'; import { useSearchParams } from 'react-router-dom'; import app from 'state'; import useUserStore from 'state/ui/user'; +import Permissions from 'utils/Permissions'; import { ThreadContestTagContainer } from 'views/components/ThreadContestTag'; import { isHot } from 'views/pages/discussions/helpers'; import Account from '../../../../models/Account'; @@ -133,6 +134,8 @@ export const CWContentPage = ({ topicId: thread?.topic?.id || 0, }); + const isAdmin = Permissions.isSiteAdmin() || Permissions.isCommunityAdmin(); + const tabSelected = useMemo(() => { const tab = Object.fromEntries(urlQueryParams.entries())?.tab; if (!tab) { @@ -213,6 +216,7 @@ export const CWContentPage = ({ const disabledReactPermissionTooltipText = getThreadActionTooltipText({ isCommunityMember: !!user.activeAccount, threadTopicInteractionRestriction: + !isAdmin && !foundTopicPermissions?.permission?.includes( GroupTopicPermissionEnum.UPVOTE, ) @@ -223,6 +227,7 @@ export const CWContentPage = ({ const disabledCommentPermissionTooltipText = getThreadActionTooltipText({ isCommunityMember: !!user.activeAccount, threadTopicInteractionRestriction: + !isAdmin && !foundTopicPermissions?.permission?.includes( GroupTopicPermissionEnum.UPVOTE_AND_COMMENT, ) diff --git a/packages/commonwealth/client/scripts/views/components/feed.tsx b/packages/commonwealth/client/scripts/views/components/feed.tsx index e047e63a98d..7801126a0ab 100644 --- a/packages/commonwealth/client/scripts/views/components/feed.tsx +++ b/packages/commonwealth/client/scripts/views/components/feed.tsx @@ -62,12 +62,20 @@ const FeedThread = ({ thread }: { thread: Thread }) => { topicId: thread?.topic?.id || 0, }); + const isAdmin = + Permissions.isSiteAdmin() || + Permissions.isCommunityAdmin({ + id: community?.id || '', + adminsAndMods: community?.adminsAndMods || [], + }); + const disabledActionsTooltipText = getThreadActionTooltipText({ isCommunityMember: Permissions.isCommunityMember(thread.communityId), isThreadArchived: !!thread?.archivedAt, isThreadLocked: !!thread?.lockedAt, isThreadTopicGated: isRestrictedMembership, threadTopicInteractionRestriction: + !isAdmin && !foundTopicPermissions?.permission?.includes( GroupTopicPermissionEnum.UPVOTE_AND_COMMENT, // on this page we only show comment option ) diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx index 3e6af844801..9a552caf48c 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx @@ -235,6 +235,7 @@ const DiscussionsPage = ({ topicName }: DiscussionsPageProps) => { isThreadLocked: !!thread?.lockedAt, isThreadTopicGated: isRestrictedMembership, threadTopicInteractionRestriction: + !isAdmin && !foundTopicPermissions?.permission?.includes( GroupTopicPermissionEnum.UPVOTE, ) @@ -246,6 +247,7 @@ const DiscussionsPage = ({ topicName }: DiscussionsPageProps) => { { isCommunityMember: !!user.activeAccount, threadTopicInteractionRestriction: + !isAdmin && !foundTopicPermissions?.permission?.includes( GroupTopicPermissionEnum.UPVOTE, ) @@ -258,6 +260,7 @@ const DiscussionsPage = ({ topicName }: DiscussionsPageProps) => { getThreadActionTooltipText({ isCommunityMember: !!user.activeAccount, threadTopicInteractionRestriction: + !isAdmin && !foundTopicPermissions?.permission?.includes( GroupTopicPermissionEnum.UPVOTE_AND_COMMENT, ) diff --git a/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx b/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx index 815ee7f2508..21f6cd9ba02 100644 --- a/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx +++ b/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx @@ -119,6 +119,7 @@ export const TopicSummaryRow = ({ isThreadLocked: !!thread?.lockedAt, isThreadTopicGated: isRestrictedMembership, threadTopicInteractionRestriction: + !isAdmin && !foundTopicPermissions?.permission?.includes( GroupTopicPermissionEnum.UPVOTE_AND_COMMENT, // on this page we only show comment option ) diff --git a/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx b/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx index e36cf0f454d..8fc60f0cbee 100644 --- a/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx @@ -332,6 +332,7 @@ const ViewThreadPage = ({ identifier }: ViewThreadPageProps) => { isThreadLocked: !!thread?.lockedAt, isThreadTopicGated: isRestrictedMembership, threadTopicInteractionRestriction: + !isAdmin && !foundTopicPermissions?.permission?.includes( GroupTopicPermissionEnum.UPVOTE_AND_COMMENT, ) From 1f42e8ce43bb50238e70878a3aeda6c5c078f9d3 Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Fri, 11 Oct 2024 14:12:12 +0500 Subject: [PATCH 15/27] Fixed failing unit tests --- libs/model/test/thread/thread-lifecycle.spec.ts | 6 ++++++ libs/schemas/src/index.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/libs/model/test/thread/thread-lifecycle.spec.ts b/libs/model/test/thread/thread-lifecycle.spec.ts index 866dc4c2ff7..4d04763e4df 100644 --- a/libs/model/test/thread/thread-lifecycle.spec.ts +++ b/libs/model/test/thread/thread-lifecycle.spec.ts @@ -173,6 +173,12 @@ describe('Thread lifecycle', () => { group_id: commentGroupId, allowed_actions: [schemas.PermissionEnum.CREATE_COMMENT], }); + await seed('GroupTopicPermission', { + group_id: threadGroupId, + topic_id: _community?.topics?.[0]?.id || 0, + allowed_actions: + schemas.GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, + }); community = _community!; roles.forEach((role) => { diff --git a/libs/schemas/src/index.ts b/libs/schemas/src/index.ts index d5c6785c811..fbdfcd5d0d9 100644 --- a/libs/schemas/src/index.ts +++ b/libs/schemas/src/index.ts @@ -24,6 +24,7 @@ export type Aggregates = Extract< | 'GroupPermission' | 'Tags' | 'CommunityTags' + | 'GroupTopicPermission' >; export * from './commands'; From 7f553b31c04f9e47e1c693b5189ee1acd7e168c1 Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Fri, 11 Oct 2024 20:58:56 +0500 Subject: [PATCH 16/27] Rename prop type --- packages/commonwealth/client/scripts/hooks/useTopicGating.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/commonwealth/client/scripts/hooks/useTopicGating.ts b/packages/commonwealth/client/scripts/hooks/useTopicGating.ts index fa94050c3e1..1875fd3a00a 100644 --- a/packages/commonwealth/client/scripts/hooks/useTopicGating.ts +++ b/packages/commonwealth/client/scripts/hooks/useTopicGating.ts @@ -4,7 +4,7 @@ import Permissions from '../utils/Permissions'; type TopicPermission = { id: number; permission: GroupTopicPermissionEnum }; -type IuseTopicGating = { +type UseTopicGatingProps = { communityId: string; apiEnabled: boolean; userAddress: string; @@ -16,7 +16,7 @@ const useTopicGating = ({ communityId, userAddress, topicId, -}: IuseTopicGating) => { +}: UseTopicGatingProps) => { const { data: memberships = [], isLoading: isLoadingMemberships } = useRefreshMembershipQuery({ communityId, From d230070ad315f71445dc4b8133ffdf12f808cee0 Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Wed, 16 Oct 2024 00:24:50 +0500 Subject: [PATCH 17/27] Unified `GroupTopicPermissions` model into `GroupPermissions` --- .../src/comment/CreateComment.command.ts | 1 - .../comment/CreateCommentReaction.command.ts | 1 - .../src/community/CreateGroup.command.ts | 4 +- .../src/community/UpdateGroup.command.ts | 4 +- libs/model/src/middleware/authorization.ts | 33 +++------ libs/model/src/models/associations.ts | 4 +- libs/model/src/models/factories.ts | 2 - libs/model/src/models/groupPermission.ts | 7 ++ libs/model/src/models/groupTopicPermission.ts | 46 ------------- libs/model/src/models/index.ts | 1 - libs/model/src/thread/CreateThread.command.ts | 4 +- .../thread/CreateThreadReaction.command.ts | 1 - .../schemas/src/commands/community.schemas.ts | 6 +- .../src/entities/group-permission.schemas.ts | 4 +- .../entities/groupTopicPermission.schemas.ts | 18 ----- libs/schemas/src/entities/index.ts | 1 - libs/schemas/src/index.ts | 1 - .../client/scripts/helpers/threads.ts | 15 ++-- .../client/scripts/hooks/useTopicGating.ts | 8 ++- .../state/api/groups/refreshMembership.ts | 4 +- .../NewThreadFormLegacy/NewThreadForm.tsx | 18 ++--- .../NewThreadFormModern/NewThreadForm.tsx | 18 ++--- .../CWContentPage/CWContentPage.tsx | 21 +++--- .../CWGatedTopicPermissionLevelBanner.tsx | 11 +-- .../client/scripts/views/components/feed.tsx | 22 ++++-- .../Update/UpdateCommunityGroupPage.tsx | 5 +- .../Groups/common/GroupForm/GroupForm.tsx | 7 +- .../Groups/common/GroupForm/constants.ts | 5 +- .../Groups/common/GroupForm/helpers.ts | 59 ++++++++++++++++ .../Groups/common/GroupForm/index.types.ts | 12 +++- .../GroupsSection/GroupCard/GroupCard.tsx | 6 +- .../Members/GroupsSection/GroupCard/types.ts | 4 +- .../Members/GroupsSection/GroupsSection.tsx | 2 +- .../discussions/CommentTree/CommentTree.tsx | 5 ++ .../pages/discussions/DiscussionsPage.tsx | 29 +++----- .../views/pages/overview/TopicSummaryRow.tsx | 10 +-- .../pages/view_thread/ViewThreadPage.tsx | 10 +-- .../server_groups_methods/get_groups.ts | 17 +++-- .../refresh_membership.ts | 10 +-- ...02720-add-group-topic-permissions-table.js | 42 ----------- ...igrate-existing-group-topic-permissions.js | 30 -------- ...d-for-each-group_id-in-GroupPermissions.js | 69 +++++++++++++++++++ ...igrate-existing-group-topic-permissions.js | 53 ++++++++++++++ 43 files changed, 344 insertions(+), 286 deletions(-) delete mode 100644 libs/model/src/models/groupTopicPermission.ts delete mode 100644 libs/schemas/src/entities/groupTopicPermission.schemas.ts create mode 100644 packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/helpers.ts delete mode 100644 packages/commonwealth/server/migrations/20241010102720-add-group-topic-permissions-table.js delete mode 100644 packages/commonwealth/server/migrations/20241010195545-migrate-existing-group-topic-permissions.js create mode 100644 packages/commonwealth/server/migrations/20241015145424-add-topic_id-for-each-group_id-in-GroupPermissions.js create mode 100644 packages/commonwealth/server/migrations/20241015181202-migrate-existing-group-topic-permissions.js diff --git a/libs/model/src/comment/CreateComment.command.ts b/libs/model/src/comment/CreateComment.command.ts index 8b90598213a..61baa501415 100644 --- a/libs/model/src/comment/CreateComment.command.ts +++ b/libs/model/src/comment/CreateComment.command.ts @@ -34,7 +34,6 @@ export function CreateComment(): Command< auth: [ isAuthorized({ action: schemas.PermissionEnum.CREATE_COMMENT, - topicPermission: schemas.GroupTopicPermissionEnum.UPVOTE_AND_COMMENT, }), verifyCommentSignature, ], diff --git a/libs/model/src/comment/CreateCommentReaction.command.ts b/libs/model/src/comment/CreateCommentReaction.command.ts index a23a1dedaad..89af9a81ab4 100644 --- a/libs/model/src/comment/CreateCommentReaction.command.ts +++ b/libs/model/src/comment/CreateCommentReaction.command.ts @@ -15,7 +15,6 @@ export function CreateCommentReaction(): Command< auth: [ isAuthorized({ action: schemas.PermissionEnum.CREATE_COMMENT_REACTION, - topicPermission: schemas.GroupTopicPermissionEnum.UPVOTE, }), verifyReactionSignature, ], diff --git a/libs/model/src/community/CreateGroup.command.ts b/libs/model/src/community/CreateGroup.command.ts index 810003bc022..514265c9bf4 100644 --- a/libs/model/src/community/CreateGroup.command.ts +++ b/libs/model/src/community/CreateGroup.command.ts @@ -75,11 +75,11 @@ export function CreateGroup(): Command< await Promise.all( (payload.topics || [])?.map(async (t) => { if (group.id) { - await models.GroupTopicPermission.create( + await models.GroupPermission.create( { group_id: group.id, topic_id: t.id, - allowed_actions: t.permission, + allowed_actions: t.permissions, }, { transaction }, ); diff --git a/libs/model/src/community/UpdateGroup.command.ts b/libs/model/src/community/UpdateGroup.command.ts index ffe06a9623f..3af2203dcae 100644 --- a/libs/model/src/community/UpdateGroup.command.ts +++ b/libs/model/src/community/UpdateGroup.command.ts @@ -95,9 +95,9 @@ export function UpdateGroup(): Command< await Promise.all( (payload.topics || [])?.map(async (t) => { if (group.id) { - await models.GroupTopicPermission.update( + await models.GroupPermission.update( { - allowed_actions: t.permission, + allowed_actions: t.permissions, }, { where: { diff --git a/libs/model/src/middleware/authorization.ts b/libs/model/src/middleware/authorization.ts index 53991a1b097..2045377a3eb 100644 --- a/libs/model/src/middleware/authorization.ts +++ b/libs/model/src/middleware/authorization.ts @@ -9,7 +9,6 @@ import { import { Group, GroupPermissionAction, - GroupTopicPermissionEnum, } from '@hicommonwealth/schemas'; import { Role } from '@hicommonwealth/shared'; import { Op, QueryTypes } from 'sequelize'; @@ -53,7 +52,7 @@ export class NonMember extends InvalidActor { constructor( public actor: Actor, public topic: string, - public action: GroupPermissionAction | GroupTopicPermissionEnum, + public action: GroupPermissionAction, ) { super( actor, @@ -190,7 +189,6 @@ async function hasTopicInteractionPermissions( actor: Actor, auth: AuthContext, action: GroupPermissionAction, - topicPermission: GroupTopicPermissionEnum, ): Promise { if (!auth.topic_id) throw new InvalidInput('Must provide a topic id'); @@ -199,28 +197,27 @@ async function hasTopicInteractionPermissions( if (auth.topic.group_ids?.length === 0) return; + // check if user has permission to perform "action" in 'topic_id' + // the 'topic_id' can belong to any group where user has membership + // the group with 'topic_id' having higher permissions will take precedence const groups = await models.sequelize.query< z.infer & { - group_allowed_actions?: GroupPermissionAction[]; - topic_allowed_actions?: GroupTopicPermissionEnum; + allowed_actions?: GroupPermissionAction[]; } >( ` SELECT g.*, - gp.allowed_actions as group_allowed_actions, - gtp.allowed_actions as topic_allowed_actions + gp.allowed_actions as allowed_actions FROM "Groups" as g - LEFT JOIN "GroupPermissions" gp ON g.id = gp.group_id - LEFT JOIN "GroupTopicPermissions" gtp ON g.id = gtp.group_id AND gtp.topic_id = :topic_id - WHERE g.community_id = :community_id AND g.id IN (:group_ids); + LEFT JOIN "GroupPermissions" gp ON g.id = gp.group_id AND gp.topic_id = :topic_id + WHERE g.community_id = :community_id `, { type: QueryTypes.SELECT, raw: true, replacements: { community_id: auth.topic.community_id, - group_ids: auth.topic.group_ids, topic_id: auth.topic.id, }, }, @@ -230,18 +227,11 @@ async function hasTopicInteractionPermissions( // any group_allowed_actions, or we have the new fine-grained permission system where the action must be in // the group_allowed_actions list. const allowedGroupActions = groups.filter( - (g) => !g.group_allowed_actions || g.group_allowed_actions.includes(action), + (g) => !g.allowed_actions || g.allowed_actions.includes(action), ); if (!allowedGroupActions.length!) throw new NonMember(actor, auth.topic.name, action); - // The user must have `topicPermission` matching `topic_allowed_actions` for this topic - const allowedTopicActions = groups.filter((g) => - g.topic_allowed_actions?.includes(topicPermission), - ); - if (!allowedTopicActions.length!) - throw new NonMember(actor, auth.topic.name, topicPermission); - // check membership for all groups of topic const memberships = await models.Membership.findAll({ where: { @@ -291,13 +281,11 @@ export const isSuperAdmin: AuthHandler = async (ctx) => { export function isAuthorized({ roles = ['admin', 'moderator', 'member'], action, - topicPermission, author = false, collaborators = false, }: { roles?: Role[]; action?: GroupPermissionAction; - topicPermission?: GroupTopicPermissionEnum; author?: boolean; collaborators?: boolean; }): AuthHandler { @@ -314,13 +302,12 @@ export function isAuthorized({ if (auth.address!.is_banned) throw new BannedActor(ctx.actor); - if (action && topicPermission) { + if (action) { // waterfall stops here after validating the action await hasTopicInteractionPermissions( ctx.actor, auth, action, - topicPermission, ); return; } diff --git a/libs/model/src/models/associations.ts b/libs/model/src/models/associations.ts index 88bfb03d332..7f3ec6b1038 100644 --- a/libs/model/src/models/associations.ts +++ b/libs/model/src/models/associations.ts @@ -95,7 +95,7 @@ export const buildAssociations = (db: DB) => { onDelete: 'SET NULL', }) .withMany(db.ContestTopic, { asMany: 'contest_topics' }) - .withMany(db.GroupTopicPermission, { + .withMany(db.GroupPermission, { foreignKey: 'topic_id', onUpdate: 'CASCADE', onDelete: 'CASCADE', @@ -140,7 +140,7 @@ export const buildAssociations = (db: DB) => { onDelete: 'CASCADE', }); - db.Group.withMany(db.GroupPermission).withMany(db.GroupTopicPermission, { + db.Group.withMany(db.GroupPermission, { foreignKey: 'group_id', onUpdate: 'CASCADE', onDelete: 'CASCADE', diff --git a/libs/model/src/models/factories.ts b/libs/model/src/models/factories.ts index 0e093b0e402..b9148131185 100644 --- a/libs/model/src/models/factories.ts +++ b/libs/model/src/models/factories.ts @@ -24,7 +24,6 @@ import EmailUpdateToken from './email_update_token'; import EvmEventSource from './evmEventSource'; import Group from './group'; import GroupPermission from './groupPermission'; -import GroupTopicPermission from './groupTopicPermission'; import LastProcessedEvmBlock from './lastProcessedEvmBlock'; import Membership from './membership'; import Outbox from './outbox'; @@ -92,7 +91,6 @@ export const Factories = { Wallets, Token, XpLog, - GroupTopicPermission, }; export type DB = { diff --git a/libs/model/src/models/groupPermission.ts b/libs/model/src/models/groupPermission.ts index d75f23d68d8..932e89313a2 100644 --- a/libs/model/src/models/groupPermission.ts +++ b/libs/model/src/models/groupPermission.ts @@ -2,11 +2,13 @@ import { GroupPermission } from '@hicommonwealth/schemas'; import Sequelize from 'sequelize'; // must use "* as" to avoid scope errors import { z } from 'zod'; import { GroupAttributes } from './group'; +import { TopicAttributes } from './topic'; import type { ModelInstance } from './types'; export type GroupPermissionAttributes = z.infer & { // associations Group?: GroupAttributes; + Topic?: TopicAttributes; }; export type GroupPermissionInstance = ModelInstance; @@ -22,6 +24,11 @@ export default ( allowNull: false, primaryKey: true, }, + topic_id: { + type: Sequelize.INTEGER, + allowNull: false, + primaryKey: true, + }, allowed_actions: { // This needs to be a string[] because enum[] will break sequelize.sync and fail tests type: Sequelize.ARRAY(Sequelize.STRING), diff --git a/libs/model/src/models/groupTopicPermission.ts b/libs/model/src/models/groupTopicPermission.ts deleted file mode 100644 index 0d8454fb838..00000000000 --- a/libs/model/src/models/groupTopicPermission.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { GroupTopicPermission } from '@hicommonwealth/schemas'; -import Sequelize from 'sequelize'; -import { z } from 'zod'; -import { GroupAttributes } from './group'; -import { TopicAttributes } from './topic'; -import type { ModelInstance } from './types'; - -export type GroupTopicPermissionAttributes = z.infer< - typeof GroupTopicPermission -> & { - // associations - Group?: GroupAttributes; - Topic?: TopicAttributes; -}; - -export type GroupTopicPermissionInstance = - ModelInstance; - -export default ( - sequelize: Sequelize.Sequelize, -): Sequelize.ModelStatic => - sequelize.define( - 'GroupTopicPermission', - { - group_id: { - type: Sequelize.INTEGER, - allowNull: false, - primaryKey: true, - }, - topic_id: { - type: Sequelize.INTEGER, - allowNull: false, - primaryKey: true, - }, - allowed_actions: { - type: Sequelize.STRING, - allowNull: false, - }, - }, - { - tableName: 'GroupTopicPermissions', - timestamps: true, - createdAt: 'created_at', - updatedAt: 'updated_at', - }, - ); diff --git a/libs/model/src/models/index.ts b/libs/model/src/models/index.ts index bf06e279662..99c49cf8464 100644 --- a/libs/model/src/models/index.ts +++ b/libs/model/src/models/index.ts @@ -61,7 +61,6 @@ export * from './discord_bot_config'; export * from './email_update_token'; export * from './evmEventSource'; export * from './group'; -export * from './groupTopicPermission'; export * from './lastProcessedEvmBlock'; export * from './membership'; export * from './outbox'; diff --git a/libs/model/src/thread/CreateThread.command.ts b/libs/model/src/thread/CreateThread.command.ts index ac65619450f..e7faf3af990 100644 --- a/libs/model/src/thread/CreateThread.command.ts +++ b/libs/model/src/thread/CreateThread.command.ts @@ -89,9 +89,7 @@ export function CreateThread(): Command< ...schemas.CreateThread, auth: [ isAuthorized({ - action: schemas.PermissionEnum.CREATE_THREAD, - topicPermission: - schemas.GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, + action: schemas.PermissionEnum.CREATE_THREAD }), verifyThreadSignature, ], diff --git a/libs/model/src/thread/CreateThreadReaction.command.ts b/libs/model/src/thread/CreateThreadReaction.command.ts index 8dc4e33d5e5..935360ce7ae 100644 --- a/libs/model/src/thread/CreateThreadReaction.command.ts +++ b/libs/model/src/thread/CreateThreadReaction.command.ts @@ -19,7 +19,6 @@ export function CreateThreadReaction(): Command< auth: [ isAuthorized({ action: schemas.PermissionEnum.CREATE_THREAD_REACTION, - topicPermission: schemas.GroupTopicPermissionEnum.UPVOTE, }), verifyReactionSignature, ], diff --git a/libs/schemas/src/commands/community.schemas.ts b/libs/schemas/src/commands/community.schemas.ts index 44e3a7f7969..22d9a5df459 100644 --- a/libs/schemas/src/commands/community.schemas.ts +++ b/libs/schemas/src/commands/community.schemas.ts @@ -12,7 +12,7 @@ import { z } from 'zod'; import { Community, Group, - GroupTopicPermissionEnum, + PermissionEnum, Requirement, StakeTransaction, Topic, @@ -233,7 +233,7 @@ export const CreateGroup = { .array( z.object({ id: PG_INT, - permission: z.nativeEnum(GroupTopicPermissionEnum), + permissions: z.array(z.nativeEnum(PermissionEnum)), }), ) .optional(), @@ -251,7 +251,7 @@ export const UpdateGroup = { .array( z.object({ id: PG_INT, - permission: z.nativeEnum(GroupTopicPermissionEnum), + permissions: z.array(z.nativeEnum(PermissionEnum)), }), ) .optional(), diff --git a/libs/schemas/src/entities/group-permission.schemas.ts b/libs/schemas/src/entities/group-permission.schemas.ts index b003e01cc65..c4d0c365ddc 100644 --- a/libs/schemas/src/entities/group-permission.schemas.ts +++ b/libs/schemas/src/entities/group-permission.schemas.ts @@ -12,9 +12,9 @@ export enum PermissionEnum { export type GroupPermissionAction = keyof typeof PermissionEnum; export const GroupPermission = z.object({ - group_id: PG_INT.optional(), + group_id: PG_INT, + topic_id: PG_INT, allowed_actions: z.array(z.nativeEnum(PermissionEnum)), - created_at: z.coerce.date().optional(), updated_at: z.coerce.date().optional(), }); diff --git a/libs/schemas/src/entities/groupTopicPermission.schemas.ts b/libs/schemas/src/entities/groupTopicPermission.schemas.ts deleted file mode 100644 index cd32e7db027..00000000000 --- a/libs/schemas/src/entities/groupTopicPermission.schemas.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { z } from 'zod'; -import { PG_INT } from '../utils'; - -export enum GroupTopicPermissionEnum { - UPVOTE = 'UPVOTE', - UPVOTE_AND_COMMENT = 'UPVOTE_AND_COMMENT', - UPVOTE_AND_COMMENT_AND_POST = 'UPVOTE_AND_COMMENT_AND_POST', -} - -export type GroupTopicPermissionAction = keyof typeof GroupTopicPermissionEnum; - -export const GroupTopicPermission = z.object({ - group_id: PG_INT, - topic_id: PG_INT, - allowed_actions: z.nativeEnum(GroupTopicPermissionEnum), - created_at: z.coerce.date().optional(), - updated_at: z.coerce.date().optional(), -}); diff --git a/libs/schemas/src/entities/index.ts b/libs/schemas/src/entities/index.ts index 074be762579..c69b2707b89 100644 --- a/libs/schemas/src/entities/index.ts +++ b/libs/schemas/src/entities/index.ts @@ -5,7 +5,6 @@ export * from './contract.schemas'; export * from './discordBotConfig.schemas'; export * from './group-permission.schemas'; export * from './group.schemas'; -export * from './groupTopicPermission.schemas'; export * from './notification.schemas'; export * from './reaction.schemas'; export * from './snapshot.schemas'; diff --git a/libs/schemas/src/index.ts b/libs/schemas/src/index.ts index fbdfcd5d0d9..d5c6785c811 100644 --- a/libs/schemas/src/index.ts +++ b/libs/schemas/src/index.ts @@ -24,7 +24,6 @@ export type Aggregates = Extract< | 'GroupPermission' | 'Tags' | 'CommunityTags' - | 'GroupTopicPermission' >; export * from './commands'; diff --git a/packages/commonwealth/client/scripts/helpers/threads.ts b/packages/commonwealth/client/scripts/helpers/threads.ts index 374c245b5ad..3a6bf52c48b 100644 --- a/packages/commonwealth/client/scripts/helpers/threads.ts +++ b/packages/commonwealth/client/scripts/helpers/threads.ts @@ -1,7 +1,8 @@ -import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; +import { PermissionEnum } from '@hicommonwealth/schemas'; import { re_weburl } from 'lib/url-validation'; import { Link, LinkSource } from 'models/Thread'; -import { TOPIC_PERMISSIONS } from '../views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/constants'; +// eslint-disable-next-line max-len +import { convertGranularPermissionsToAccumulatedPermissions } from '../views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/helpers'; export function detectURL(str: string) { if (str.slice(0, 4) !== 'http') str = `http://${str}`; // no https required because this is only used for regex match @@ -57,13 +58,13 @@ export const getThreadActionTooltipText = ({ isThreadArchived = false, isThreadLocked = false, isThreadTopicGated = false, - threadTopicInteractionRestriction, + threadTopicInteractionRestrictions, }: { isCommunityMember?: boolean; isThreadArchived?: boolean; isThreadLocked?: boolean; isThreadTopicGated?: boolean; - threadTopicInteractionRestriction?: GroupTopicPermissionEnum; + threadTopicInteractionRestrictions?: PermissionEnum[]; }): GetThreadActionTooltipTextResponse => { if (!isCommunityMember) { return getActionTooltipForNonCommunityMember; @@ -71,8 +72,10 @@ export const getThreadActionTooltipText = ({ if (isThreadArchived) return 'Thread is archived'; if (isThreadLocked) return 'Thread is locked'; if (isThreadTopicGated) return 'Topic is gated'; - if (threadTopicInteractionRestriction) { - return `Topic members are only allowed to ${TOPIC_PERMISSIONS[threadTopicInteractionRestriction]}`; + if (threadTopicInteractionRestrictions) { + return `Topic members are only allowed to ${convertGranularPermissionsToAccumulatedPermissions( + threadTopicInteractionRestrictions, + )}`; } return ''; }; diff --git a/packages/commonwealth/client/scripts/hooks/useTopicGating.ts b/packages/commonwealth/client/scripts/hooks/useTopicGating.ts index 1875fd3a00a..5eb80c682c1 100644 --- a/packages/commonwealth/client/scripts/hooks/useTopicGating.ts +++ b/packages/commonwealth/client/scripts/hooks/useTopicGating.ts @@ -1,8 +1,8 @@ -import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; +import { PermissionEnum } from '@hicommonwealth/schemas'; import { useRefreshMembershipQuery } from 'state/api/groups'; import Permissions from '../utils/Permissions'; -type TopicPermission = { id: number; permission: GroupTopicPermissionEnum }; +type TopicPermission = { id: number; permissions: PermissionEnum[] }; type UseTopicGatingProps = { communityId: string; @@ -31,7 +31,9 @@ const useTopicGating = ({ const existing = acc.find((item) => item.id === current.id); if (!existing) { acc.push(current); - } else if (current.permission.length > existing.permission.length) { + // IMP: this logic can break if `PermissionEnum` or the `GroupPermissions` + // schema is changed substantially and might not give off a ts issue. + } else if (current.permissions.length > existing.permissions.length) { // Replace with the current item if it has a longer permission string const index = acc.indexOf(existing); acc[index] = current; diff --git a/packages/commonwealth/client/scripts/state/api/groups/refreshMembership.ts b/packages/commonwealth/client/scripts/state/api/groups/refreshMembership.ts index 2f38f036e3b..f7dc9de1498 100644 --- a/packages/commonwealth/client/scripts/state/api/groups/refreshMembership.ts +++ b/packages/commonwealth/client/scripts/state/api/groups/refreshMembership.ts @@ -1,4 +1,4 @@ -import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; +import { PermissionEnum } from '@hicommonwealth/schemas'; import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; import { ApiEndpoints, SERVER_URL } from 'state/api/config'; @@ -15,7 +15,7 @@ interface RefreshMembershipProps { export interface Memberships { groupId: number; - topics: { id: number; permission: GroupTopicPermissionEnum }[]; + topics: { id: number; permissions: PermissionEnum[] }[]; isAllowed: boolean; rejectReason?: string; } diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx index b638cfd6f06..3cf9e6cb4fa 100644 --- a/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx @@ -1,4 +1,4 @@ -import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; +import { PermissionEnum } from '@hicommonwealth/schemas'; import { notifyError } from 'controllers/app/notifications'; import { SessionKeyError } from 'controllers/server/sessions'; import { parseCustomStages } from 'helpers'; @@ -222,12 +222,12 @@ export const NewThreadForm = () => { const disabledActionsTooltipText = getThreadActionTooltipText({ isCommunityMember: !!user.activeAccount, isThreadTopicGated: isRestrictedMembership, - threadTopicInteractionRestriction: + threadTopicInteractionRestrictions: !isAdmin && - !foundTopicPermissions?.permission?.includes( - GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_THREAD, ) - ? foundTopicPermissions?.permission + ? foundTopicPermissions?.permissions : undefined, }); @@ -422,12 +422,12 @@ export const NewThreadForm = () => { {canShowTopicPermissionBanner && foundTopicPermissions && !isAdmin && - !foundTopicPermissions?.permission?.includes( - GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_THREAD, ) && ( setCanShowTopicPermissionBanner(false)} /> diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx index 9e2a5c0d793..1563801a30e 100644 --- a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx @@ -1,4 +1,4 @@ -import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; +import { PermissionEnum } from '@hicommonwealth/schemas'; import { notifyError } from 'controllers/app/notifications'; import { SessionKeyError } from 'controllers/server/sessions'; import { parseCustomStages } from 'helpers'; @@ -206,12 +206,12 @@ export const NewThreadForm = () => { const disabledActionsTooltipText = getThreadActionTooltipText({ isCommunityMember: !!user.activeAccount, isThreadTopicGated: isRestrictedMembership, - threadTopicInteractionRestriction: + threadTopicInteractionRestrictions: !isAdmin && - !foundTopicPermissions?.permission?.includes( - GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_THREAD, ) - ? foundTopicPermissions?.permission + ? foundTopicPermissions?.permissions : undefined, }); @@ -396,12 +396,12 @@ export const NewThreadForm = () => { {canShowTopicPermissionBanner && foundTopicPermissions && !isAdmin && - !foundTopicPermissions?.permission?.includes( - GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_THREAD, ) && ( setCanShowTopicPermissionBanner(false)} /> diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx b/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx index df0226e0738..d0af21acb68 100644 --- a/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx +++ b/packages/commonwealth/client/scripts/views/components/component_kit/CWContentPage/CWContentPage.tsx @@ -1,4 +1,4 @@ -import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; +import { PermissionEnum } from '@hicommonwealth/schemas'; import { getThreadActionTooltipText } from 'helpers/threads'; import { truncate } from 'helpers/truncate'; import useTopicGating from 'hooks/useTopicGating'; @@ -215,23 +215,26 @@ export const CWContentPage = ({ const disabledReactPermissionTooltipText = getThreadActionTooltipText({ isCommunityMember: !!user.activeAccount, - threadTopicInteractionRestriction: + threadTopicInteractionRestrictions: !isAdmin && - !foundTopicPermissions?.permission?.includes( - GroupTopicPermissionEnum.UPVOTE, + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_COMMENT_REACTION, + ) && + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_THREAD_REACTION, ) - ? foundTopicPermissions?.permission + ? foundTopicPermissions?.permissions : undefined, }); const disabledCommentPermissionTooltipText = getThreadActionTooltipText({ isCommunityMember: !!user.activeAccount, - threadTopicInteractionRestriction: + threadTopicInteractionRestrictions: !isAdmin && - !foundTopicPermissions?.permission?.includes( - GroupTopicPermissionEnum.UPVOTE_AND_COMMENT, + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_COMMENT, ) - ? foundTopicPermissions?.permission + ? foundTopicPermissions?.permissions : undefined, }); diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/CWGatedTopicPermissionLevelBanner/CWGatedTopicPermissionLevelBanner.tsx b/packages/commonwealth/client/scripts/views/components/component_kit/CWGatedTopicPermissionLevelBanner/CWGatedTopicPermissionLevelBanner.tsx index 9ecdce4349c..15c4e6f2031 100644 --- a/packages/commonwealth/client/scripts/views/components/component_kit/CWGatedTopicPermissionLevelBanner/CWGatedTopicPermissionLevelBanner.tsx +++ b/packages/commonwealth/client/scripts/views/components/component_kit/CWGatedTopicPermissionLevelBanner/CWGatedTopicPermissionLevelBanner.tsx @@ -1,8 +1,9 @@ -import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; +import { PermissionEnum } from '@hicommonwealth/schemas'; import { useBrowserAnalyticsTrack } from 'hooks/useBrowserAnalyticsTrack'; import { useCommonNavigate } from 'navigation/helpers'; import React from 'react'; -import { TOPIC_PERMISSIONS } from 'views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/constants'; +// eslint-disable-next-line max-len +import { convertGranularPermissionsToAccumulatedPermissions } from 'views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/helpers'; import { MixpanelClickthroughEvent, MixpanelClickthroughPayload, @@ -12,12 +13,12 @@ import CWBanner from '../new_designs/CWBanner'; interface CWGatedTopicPermissionLevelBannerProps { onClose: () => void; - topicPermission: GroupTopicPermissionEnum; + topicPermissions: PermissionEnum[]; } const CWGatedTopicPermissionLevelBanner = ({ onClose = () => {}, - topicPermission, + topicPermissions, }: CWGatedTopicPermissionLevelBannerProps) => { const navigate = useCommonNavigate(); @@ -31,7 +32,7 @@ const CWGatedTopicPermissionLevelBanner = ({ return ( { isThreadArchived: !!thread?.archivedAt, isThreadLocked: !!thread?.lockedAt, isThreadTopicGated: isRestrictedMembership, - threadTopicInteractionRestriction: + }); + + const disabledCommentActionTooltipText = getThreadActionTooltipText({ + isCommunityMember: Permissions.isCommunityMember(thread.communityId), + threadTopicInteractionRestrictions: !isAdmin && - !foundTopicPermissions?.permission?.includes( - GroupTopicPermissionEnum.UPVOTE_AND_COMMENT, // on this page we only show comment option + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_COMMENT, // on this page we only show comment option ) - ? foundTopicPermissions?.permission + ? foundTopicPermissions?.permissions : undefined, }); @@ -94,7 +98,7 @@ const FeedThread = ({ thread }: { thread: Thread }) => { { navigate( @@ -105,7 +109,11 @@ const FeedThread = ({ thread }: { thread: Thread }) => { }} threadHref={discussionLink} onCommentBtnClick={() => navigate(`${discussionLink}?focusComments=true`)} - disabledActionsTooltipText={disabledActionsTooltipText} + disabledActionsTooltipText={ + disabledCommentActionTooltipText + ? disabledCommentActionTooltipText + : disabledActionsTooltipText + } customStages={community.custom_stages} hideReactionButton hideUpvotesDrawer diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/Update/UpdateCommunityGroupPage.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/Update/UpdateCommunityGroupPage.tsx index a61adc5f918..680e5c6375b 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/Update/UpdateCommunityGroupPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/Update/UpdateCommunityGroupPage.tsx @@ -21,6 +21,7 @@ import { import { convertRequirementAmountFromWeiToTokens } from '../../common/helpers'; import { DeleteGroupModal } from '../DeleteGroupModal'; import { GroupForm } from '../common/GroupForm'; +import { convertGranularPermissionsToAccumulatedPermissions } from '../common/GroupForm/helpers'; import { makeGroupDataBaseAPIPayload } from '../common/helpers'; import './UpdateCommunityGroupPage.scss'; @@ -131,7 +132,9 @@ const UpdateCommunityGroupPage = ({ groupId }: { groupId: string }) => { topics: (foundGroup.topics || []).map((topic) => ({ label: topic.name, value: topic.id, - permission: topic.permission, + permission: convertGranularPermissionsToAccumulatedPermissions( + topic.permissions || [], + ), })), }} onSubmit={(values) => { diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.tsx index d9f70414617..0c52bb76d9d 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/GroupForm.tsx @@ -31,6 +31,7 @@ import { REVERSED_TOPIC_PERMISSIONS, TOPIC_PERMISSIONS, } from './constants'; +import { convertAccumulatedPermissionsToGranularPermissions } from './helpers'; import { FormSubmitValues, GroupFormProps, @@ -213,7 +214,7 @@ const GroupForm = ({ if (initialValues.topics) { setTopicPermissionsSubForms( initialValues.topics.map((t) => ({ - permission: TOPIC_PERMISSIONS[t.permission], + permission: t.permission, topic: { id: parseInt(`${t.value}`), name: t.label }, })), ); @@ -388,7 +389,9 @@ const GroupForm = ({ ...values, topics: topicPermissionsSubForms.map((t) => ({ id: t.topic.id, - permission: REVERSED_TOPIC_PERMISSIONS[t.permission], + permissions: convertAccumulatedPermissionsToGranularPermissions( + REVERSED_TOPIC_PERMISSIONS[t.permission], + ), })), requirementsToFulfill, requirements: requirementSubForms.map((x) => x.values), diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/constants.ts b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/constants.ts index cc27814347b..d87892352d3 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/constants.ts +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/constants.ts @@ -1,4 +1,4 @@ -import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; +import { GroupTopicPermissionEnum } from './index.types'; export const REQUIREMENTS_TO_FULFILL = { ALL_REQUIREMENTS: 'ALL', @@ -12,6 +12,9 @@ export const TOPIC_PERMISSIONS = { 'Upvote & Comment & Post', }; +export type TopicPermissions = + (typeof TOPIC_PERMISSIONS)[keyof typeof TOPIC_PERMISSIONS]; + type ReversedTopicPermissions = { [K in keyof typeof TOPIC_PERMISSIONS as (typeof TOPIC_PERMISSIONS)[K]]: K; }; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/helpers.ts b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/helpers.ts new file mode 100644 index 00000000000..f537029a80d --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/helpers.ts @@ -0,0 +1,59 @@ +import { PermissionEnum } from '@hicommonwealth/schemas'; +import { TOPIC_PERMISSIONS, TopicPermissions } from './constants'; +import { GroupTopicPermissionEnum } from './index.types'; + +export const convertAccumulatedPermissionsToGranularPermissions = ( + permission: GroupTopicPermissionEnum, +): PermissionEnum[] => { + const basePermissions = [ + PermissionEnum.CREATE_COMMENT_REACTION, + PermissionEnum.CREATE_THREAD_REACTION, + ]; + + switch (permission) { + case GroupTopicPermissionEnum.UPVOTE: + return basePermissions; + case GroupTopicPermissionEnum.UPVOTE_AND_COMMENT: + return [...basePermissions, PermissionEnum.CREATE_COMMENT]; + case GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST: + return [ + ...basePermissions, + PermissionEnum.CREATE_COMMENT, + PermissionEnum.CREATE_THREAD, + ]; + default: + return []; + } +}; + +export const convertGranularPermissionsToAccumulatedPermissions = ( + permissions: PermissionEnum[], +): TopicPermissions => { + if ( + permissions.includes(PermissionEnum.CREATE_COMMENT) && + permissions.includes(PermissionEnum.CREATE_COMMENT_REACTION) && + permissions.includes(PermissionEnum.CREATE_THREAD_REACTION) && + permissions.includes(PermissionEnum.CREATE_THREAD) + ) { + return TOPIC_PERMISSIONS[ + GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST + ]; + } + + if ( + permissions.includes(PermissionEnum.CREATE_COMMENT) && + permissions.includes(PermissionEnum.CREATE_COMMENT_REACTION) && + permissions.includes(PermissionEnum.CREATE_THREAD_REACTION) + ) { + return TOPIC_PERMISSIONS[GroupTopicPermissionEnum.UPVOTE_AND_COMMENT]; + } + + if ( + permissions.includes(PermissionEnum.CREATE_COMMENT_REACTION) && + permissions.includes(PermissionEnum.CREATE_THREAD_REACTION) + ) { + return TOPIC_PERMISSIONS[GroupTopicPermissionEnum.UPVOTE]; + } + + return TOPIC_PERMISSIONS.UPVOTE_AND_COMMENT_AND_POST; +}; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/index.types.ts b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/index.types.ts index 17f04e35fa4..21b5783bed9 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/index.types.ts +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Groups/common/GroupForm/index.types.ts @@ -1,6 +1,12 @@ -import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; +import { PermissionEnum } from '@hicommonwealth/schemas'; import { TOPIC_PERMISSIONS } from './constants'; +export enum GroupTopicPermissionEnum { + UPVOTE = 'UPVOTE', + UPVOTE_AND_COMMENT = 'UPVOTE_AND_COMMENT', + UPVOTE_AND_COMMENT_AND_POST = 'UPVOTE_AND_COMMENT_AND_POST', +} + export type RequirementSubFormsState = { defaultValues?: RequirementSubTypeWithLabel; values: RequirementSubType; @@ -54,7 +60,7 @@ export type RequirementSubFormType = { export type GroupFormTopicSubmitValues = { id: number; - permission: GroupTopicPermissionEnum; + permissions: PermissionEnum[]; }; export type GroupResponseValuesType = { @@ -71,7 +77,7 @@ export type GroupInitialValuesTypeWithLabel = { groupDescription?: string; requirements?: RequirementSubTypeWithLabel[]; requirementsToFulfill?: 'ALL' | number; - topics: (LabelType & { permission: GroupTopicPermissionEnum })[]; + topics: (LabelType & { permission: TopicPermissions })[]; }; export type FormSubmitValues = { diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.tsx index 31aa6c2a5d7..17067a7461d 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/GroupCard.tsx @@ -8,7 +8,7 @@ import { CWText } from 'views/components/component_kit/cw_text'; import { CWTag } from 'views/components/component_kit/new_designs/CWTag'; import { formatAddressShort } from '../../../../../../helpers'; import CWPagination from '../../../../../components/component_kit/new_designs/CWPagination/CWPagination'; -import { TOPIC_PERMISSIONS } from '../../../Groups/common/GroupForm/constants'; +import { convertGranularPermissionsToAccumulatedPermissions } from '../../../Groups/common/GroupForm/helpers'; import './GroupCard.scss'; import RequirementCard from './RequirementCard/RequirementCard'; import { GroupCardProps } from './types'; @@ -155,7 +155,9 @@ const GroupCard = ({ {t.name}
diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/types.ts b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/types.ts index 954b4c038d5..3444c2477b1 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/types.ts +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupCard/types.ts @@ -1,5 +1,5 @@ +import { PermissionEnum } from '@hicommonwealth/schemas'; import MinimumProfile from 'models/MinimumProfile'; -import { TopicPermissions } from '../../../Groups/common/GroupForm/index.types'; export type RequirementCardProps = { requirementType: string; @@ -17,7 +17,7 @@ export type GroupCardProps = { requirements?: RequirementCardProps[]; // This represents erc requirements requirementsToFulfill: 'ALL' | number; allowLists?: string[]; - topics: { id: number; name: string; permission?: TopicPermissions }[]; + topics: { id: number; name: string; permissions?: PermissionEnum[] }[]; canEdit?: boolean; onEditClick?: () => void; profiles?: Map; diff --git a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupsSection.tsx b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupsSection.tsx index 585b836a54b..77f0ffc1612 100644 --- a/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupsSection.tsx +++ b/packages/commonwealth/client/scripts/views/pages/CommunityGroupsAndMembers/Members/GroupsSection/GroupsSection.tsx @@ -92,7 +92,7 @@ const GroupsSection = ({ topics={(group?.topics || []).map((x) => ({ id: x.id, name: x.name, - permission: x.permission, + permissions: x.permissions, }))} canEdit={canManageGroups} onEditClick={() => navigate(`/members/groups/${group.id}/update`)} diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/CommentTree.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/CommentTree.tsx index 35c2a4abe34..332efefd0b1 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/CommentTree.tsx +++ b/packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/CommentTree.tsx @@ -530,6 +530,11 @@ export const CommentTree = ({ parentCommentId={parentCommentId} rootThread={thread} canComment={canComment} + tooltipText={ + !canComment && disabledActionsTooltipText + ? disabledActionsTooltipText + : '' + } /> )} diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx index 9a552caf48c..be63bf09eb6 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx @@ -1,7 +1,4 @@ -import { - GroupTopicPermissionEnum, - TopicWeightedVoting, -} from '@hicommonwealth/schemas'; +import { PermissionEnum, TopicWeightedVoting } from '@hicommonwealth/schemas'; import { getProposalUrlPath } from 'identifiers'; import { getScopePrefix, useCommonNavigate } from 'navigation/helpers'; import React, { useEffect, useRef, useState } from 'react'; @@ -234,24 +231,18 @@ const DiscussionsPage = ({ topicName }: DiscussionsPageProps) => { isThreadArchived: !!thread?.archivedAt, isThreadLocked: !!thread?.lockedAt, isThreadTopicGated: isRestrictedMembership, - threadTopicInteractionRestriction: - !isAdmin && - !foundTopicPermissions?.permission?.includes( - GroupTopicPermissionEnum.UPVOTE, - ) - ? foundTopicPermissions?.permission - : undefined, }); const disabledReactPermissionTooltipText = getThreadActionTooltipText( { isCommunityMember: !!user.activeAccount, - threadTopicInteractionRestriction: + threadTopicInteractionRestrictions: !isAdmin && - !foundTopicPermissions?.permission?.includes( - GroupTopicPermissionEnum.UPVOTE, + !foundTopicPermissions?.permissions?.includes( + // this should be updated if we start displaying recent comments on this page + PermissionEnum.CREATE_THREAD_REACTION, ) - ? foundTopicPermissions?.permission + ? foundTopicPermissions?.permissions : undefined, }, ); @@ -259,12 +250,12 @@ const DiscussionsPage = ({ topicName }: DiscussionsPageProps) => { const disabledCommentPermissionTooltipText = getThreadActionTooltipText({ isCommunityMember: !!user.activeAccount, - threadTopicInteractionRestriction: + threadTopicInteractionRestrictions: !isAdmin && - !foundTopicPermissions?.permission?.includes( - GroupTopicPermissionEnum.UPVOTE_AND_COMMENT, + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_COMMENT, ) - ? foundTopicPermissions?.permission + ? foundTopicPermissions?.permissions : undefined, }); diff --git a/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx b/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx index 21f6cd9ba02..574abdb5fd4 100644 --- a/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx +++ b/packages/commonwealth/client/scripts/views/pages/overview/TopicSummaryRow.tsx @@ -1,4 +1,4 @@ -import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; +import { PermissionEnum } from '@hicommonwealth/schemas'; import { slugify } from '@hicommonwealth/shared'; import { getThreadActionTooltipText } from 'helpers/threads'; import useTopicGating from 'hooks/useTopicGating'; @@ -118,12 +118,12 @@ export const TopicSummaryRow = ({ isThreadArchived: !!thread?.archivedAt, isThreadLocked: !!thread?.lockedAt, isThreadTopicGated: isRestrictedMembership, - threadTopicInteractionRestriction: + threadTopicInteractionRestrictions: !isAdmin && - !foundTopicPermissions?.permission?.includes( - GroupTopicPermissionEnum.UPVOTE_AND_COMMENT, // on this page we only show comment option + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_COMMENT, // on this page we only show comment option ) - ? foundTopicPermissions?.permission + ? foundTopicPermissions?.permissions : undefined, }); diff --git a/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx b/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx index 8fc60f0cbee..2dc1cd8caba 100644 --- a/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx @@ -1,4 +1,4 @@ -import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; +import { PermissionEnum } from '@hicommonwealth/schemas'; import { ContentType, getThreadUrl } from '@hicommonwealth/shared'; import { notifyError } from 'controllers/app/notifications'; import { extractDomain, isDefaultStage } from 'helpers'; @@ -331,12 +331,12 @@ const ViewThreadPage = ({ identifier }: ViewThreadPageProps) => { isThreadArchived: !!thread?.archivedAt, isThreadLocked: !!thread?.lockedAt, isThreadTopicGated: isRestrictedMembership, - threadTopicInteractionRestriction: + threadTopicInteractionRestrictions: !isAdmin && - !foundTopicPermissions?.permission?.includes( - GroupTopicPermissionEnum.UPVOTE_AND_COMMENT, + !foundTopicPermissions?.permissions?.includes( + PermissionEnum.CREATE_COMMENT, ) - ? foundTopicPermissions?.permission + ? foundTopicPermissions?.permissions : undefined, }); diff --git a/packages/commonwealth/server/controllers/server_groups_methods/get_groups.ts b/packages/commonwealth/server/controllers/server_groups_methods/get_groups.ts index fcb5003a829..7bb193623fa 100644 --- a/packages/commonwealth/server/controllers/server_groups_methods/get_groups.ts +++ b/packages/commonwealth/server/controllers/server_groups_methods/get_groups.ts @@ -4,7 +4,7 @@ import { MembershipAttributes, TopicAttributes, } from '@hicommonwealth/model'; -import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; +import { PermissionEnum } from '@hicommonwealth/schemas'; import { Op, WhereOptions } from 'sequelize'; import { ServerGroupsController } from '../server_groups_controller'; @@ -15,7 +15,7 @@ export type GetGroupsOptions = { }; export type TopicAttributesWithPermission = TopicAttributes & { - permission: GroupTopicPermissionEnum; + permissions: PermissionEnum[]; }; type GroupWithExtras = GroupAttributes & { @@ -25,9 +25,9 @@ type GroupWithExtras = GroupAttributes & { export type GetGroupsResult = GroupWithExtras[]; export type GroupInstanceWithTopicPermissions = GroupInstance & { - GroupTopicPermissions: { + GroupPermissions: { topic_id: number; - allowed_actions: GroupTopicPermissionEnum; + allowed_actions: PermissionEnum[]; }[]; }; @@ -41,7 +41,7 @@ export async function __getGroups( }, include: [ { - model: this.models.GroupTopicPermission, + model: this.models.GroupPermission, attributes: ['topic_id', 'allowed_actions'], }, ], @@ -94,11 +94,10 @@ export async function __getGroups( .filter((t) => t.group_ids!.includes(group.id!)) .map((t) => { const temp: TopicAttributesWithPermission = { ...t.toJSON() }; - temp.permission = ( - (group as GroupInstanceWithTopicPermissions) - .GroupTopicPermissions || [] + temp.permissions = ( + (group as GroupInstanceWithTopicPermissions).GroupPermissions || [] ).find((gtp) => gtp.topic_id === t.id) - ?.allowed_actions as GroupTopicPermissionEnum; + ?.allowed_actions as PermissionEnum[]; return temp; }), })); diff --git a/packages/commonwealth/server/controllers/server_groups_methods/refresh_membership.ts b/packages/commonwealth/server/controllers/server_groups_methods/refresh_membership.ts index e7dc20f43ba..948b28e74ec 100644 --- a/packages/commonwealth/server/controllers/server_groups_methods/refresh_membership.ts +++ b/packages/commonwealth/server/controllers/server_groups_methods/refresh_membership.ts @@ -4,7 +4,7 @@ import { MembershipRejectReason, UserInstance, } from '@hicommonwealth/model'; -import { GroupTopicPermissionEnum } from '@hicommonwealth/schemas'; +import { PermissionEnum } from '@hicommonwealth/schemas'; import { Op } from 'sequelize'; import { refreshMembershipsForAddress } from '../../util/requirementsModule/refreshMembershipsForAddress'; import { ServerGroupsController } from '../server_groups_controller'; @@ -36,7 +36,7 @@ export async function __refreshMembership( }, include: [ { - model: this.models.GroupTopicPermission, + model: this.models.GroupPermission, attributes: ['topic_id', 'allowed_actions'], }, ], @@ -75,10 +75,10 @@ export async function __refreshMembership( .filter((t) => t.group_ids!.includes(membership.group_id)) .map((t) => ({ id: t.id, - permission: (groups as GroupInstanceWithTopicPermissions[]) + permissions: (groups as GroupInstanceWithTopicPermissions[]) .find((g) => g.id === membership.group_id) - ?.GroupTopicPermissions?.find((gtp) => gtp.topic_id === t.id) - ?.allowed_actions as GroupTopicPermissionEnum, + ?.GroupPermissions?.find((gtp) => gtp.topic_id === t.id) + ?.allowed_actions as PermissionEnum[], })), allowed: !membership.reject_reason, rejectReason: membership.reject_reason, diff --git a/packages/commonwealth/server/migrations/20241010102720-add-group-topic-permissions-table.js b/packages/commonwealth/server/migrations/20241010102720-add-group-topic-permissions-table.js deleted file mode 100644 index e6109b1c8a4..00000000000 --- a/packages/commonwealth/server/migrations/20241010102720-add-group-topic-permissions-table.js +++ /dev/null @@ -1,42 +0,0 @@ -'use strict'; - -module.exports = { - up: async (queryInterface, Sequelize) => { - return queryInterface.sequelize.transaction(async (t) => { - await queryInterface.createTable( - 'GroupTopicPermissions', - { - group_id: { - type: Sequelize.INTEGER, - allowNull: false, - primaryKey: true, - references: { model: 'Groups', key: 'id' }, - onUpdate: 'CASCADE', - onDelete: 'CASCADE', - }, - topic_id: { - type: Sequelize.INTEGER, - allowNull: false, - primaryKey: true, - references: { model: 'Topics', key: 'id' }, - onUpdate: 'CASCADE', - onDelete: 'CASCADE', - }, - allowed_actions: { type: Sequelize.STRING, allowNull: false }, - created_at: { type: Sequelize.DATE, allowNull: false }, - updated_at: { type: Sequelize.DATE, allowNull: false }, - }, - { - timestamps: true, - transactions: t, - }, - ); - }); - }, - - down: async (queryInterface, Sequelize) => { - await queryInterface.sequelize.transaction(async (transaction) => { - await queryInterface.dropTable('GroupTopicPermissions', { transaction }); - }); - }, -}; diff --git a/packages/commonwealth/server/migrations/20241010195545-migrate-existing-group-topic-permissions.js b/packages/commonwealth/server/migrations/20241010195545-migrate-existing-group-topic-permissions.js deleted file mode 100644 index 9383d7783e2..00000000000 --- a/packages/commonwealth/server/migrations/20241010195545-migrate-existing-group-topic-permissions.js +++ /dev/null @@ -1,30 +0,0 @@ -'use strict'; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - async up(queryInterface, Sequelize) { - await queryInterface.sequelize.transaction(async (t) => { - await queryInterface.sequelize.query( - ` - INSERT INTO "GroupTopicPermissions" - (group_id, topic_id, allowed_actions, created_at, updated_at) - SELECT - unnest(t.group_ids) AS group_id, - t.id AS topic_id, - 'UPVOTE_AND_COMMENT_AND_POST' AS allowed_actions, - NOW() AS created_at, - NOW() AS updated_at - FROM "Topics" t - WHERE - ARRAY_LENGTH(t.group_ids, 1) IS NOT NULL - AND ARRAY_LENGTH(t.group_ids, 1) > 0; - `, - { transaction: t }, - ); - }); - }, - - async down(queryInterface, Sequelize) { - // not really-possible/recommended to rollback since "GroupTopicPermissions" might have more entries - }, -}; diff --git a/packages/commonwealth/server/migrations/20241015145424-add-topic_id-for-each-group_id-in-GroupPermissions.js b/packages/commonwealth/server/migrations/20241015145424-add-topic_id-for-each-group_id-in-GroupPermissions.js new file mode 100644 index 00000000000..1ce2fbb5bff --- /dev/null +++ b/packages/commonwealth/server/migrations/20241015145424-add-topic_id-for-each-group_id-in-GroupPermissions.js @@ -0,0 +1,69 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + return queryInterface.sequelize.transaction(async (t) => { + await queryInterface.removeConstraint( + 'GroupPermissions', + 'GroupPermissions_group_id_fkey', + { transaction: t }, + ); + + await queryInterface.addColumn( + 'GroupPermissions', + 'topic_id', + { + type: Sequelize.INTEGER, + allowNull: false, + primaryKey: true, + references: { model: 'Topics', key: 'id' }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + { transaction: t }, + ); + + await queryInterface.changeColumn( + 'GroupPermissions', + 'group_id', + { + type: Sequelize.INTEGER, + allowNull: false, + primaryKey: true, + references: { model: 'Groups', key: 'id' }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + { transaction: t }, + ); + + await queryInterface.addConstraint('GroupPermissions', { + type: 'unique', + fields: ['group_id', 'topic_id'], + name: 'GroupPermissions_unique_composite_constraint', + transaction: t, + }); + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.sequelize.transaction(async (t) => { + await queryInterface.removeColumn('GroupPermissions', 'topic_id', { + transaction: t, + }); + await queryInterface.changeColumn( + 'GroupPermissions', + 'group_id', + { + type: Sequelize.INTEGER, + allowNull: true, + primaryKey: true, + references: { model: 'Groups', key: 'id' }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + { transaction: t }, + ); + }); + }, +}; diff --git a/packages/commonwealth/server/migrations/20241015181202-migrate-existing-group-topic-permissions.js b/packages/commonwealth/server/migrations/20241015181202-migrate-existing-group-topic-permissions.js new file mode 100644 index 00000000000..2835f567d55 --- /dev/null +++ b/packages/commonwealth/server/migrations/20241015181202-migrate-existing-group-topic-permissions.js @@ -0,0 +1,53 @@ +'use strict'; +// TODO - remove this +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async (t) => { + await queryInterface.sequelize.query( + ` + DO $$ + BEGIN IF NOT EXISTS ( + SELECT 1 + FROM pg_type + WHERE typname = 'enum_GroupPermissions_allowed_actions' + ) + THEN + CREATE TYPE enum_GroupPermissions_allowed_actions AS ENUM ( + 'CREATE_COMMENT_REACTION', + 'CREATE_THREAD_REACTION', + 'CREATE_COMMENT', + 'CREATE_THREAD' + ); + END IF; + END $$; + + + INSERT INTO "GroupPermissions" + (group_id, topic_id, allowed_actions, created_at, updated_at) + SELECT + unnest(t.group_ids) AS group_id, + t.id AS topic_id, + ARRAY[ + 'CREATE_COMMENT_REACTION', + 'CREATE_THREAD_REACTION', + 'CREATE_COMMENT', + 'CREATE_THREAD' + ]::"enum_GroupPermissions_allowed_actions"[] + AS allowed_actions, + NOW() AS created_at, + NOW() AS updated_at + FROM "Topics" t + WHERE + ARRAY_LENGTH(t.group_ids, 1) IS NOT NULL + AND ARRAY_LENGTH(t.group_ids, 1) > 0; + `, + { transaction: t }, + ); + }); + }, + + async down(queryInterface, Sequelize) { + // not really-possible/recommended to rollback since "GroupPermissions" might have more entries + }, +}; From e178ebf7dee2438c05c8d10e1487ebbf6876f24f Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Wed, 16 Oct 2024 00:25:15 +0500 Subject: [PATCH 18/27] Removed unused permission --- libs/schemas/src/entities/group-permission.schemas.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/schemas/src/entities/group-permission.schemas.ts b/libs/schemas/src/entities/group-permission.schemas.ts index c4d0c365ddc..5de7d64ddcd 100644 --- a/libs/schemas/src/entities/group-permission.schemas.ts +++ b/libs/schemas/src/entities/group-permission.schemas.ts @@ -6,7 +6,6 @@ export enum PermissionEnum { CREATE_COMMENT = 'CREATE_COMMENT', CREATE_THREAD_REACTION = 'CREATE_THREAD_REACTION', CREATE_COMMENT_REACTION = 'CREATE_COMMENT_REACTION', - UPDATE_POLL = 'UPDATE_POLL', } export type GroupPermissionAction = keyof typeof PermissionEnum; From d4d3934e0d02f91ed443ac983b8478d00c6c93f9 Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Wed, 16 Oct 2024 00:28:33 +0500 Subject: [PATCH 19/27] Updated community lifecycle test --- libs/model/test/community/community-lifecycle.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/libs/model/test/community/community-lifecycle.spec.ts b/libs/model/test/community/community-lifecycle.spec.ts index 358603b9cf3..0e16c5f3c6f 100644 --- a/libs/model/test/community/community-lifecycle.spec.ts +++ b/libs/model/test/community/community-lifecycle.spec.ts @@ -8,7 +8,7 @@ import { query, } from '@hicommonwealth/core'; import { - GroupTopicPermissionEnum, + PermissionEnum, TopicWeightedVoting, } from '@hicommonwealth/schemas'; import { ChainBase, ChainType } from '@hicommonwealth/shared'; @@ -43,7 +43,7 @@ const chance = Chance(); function buildCreateGroupPayload( community_id: string, - topics: { id: number; permission: GroupTopicPermissionEnum }[] = [], + topics: { id: number; permissions: PermissionEnum[] }[] = [], ) { return { community_id, @@ -364,15 +364,15 @@ describe('Community lifecycle', () => { payload: buildCreateGroupPayload(community.id, [ { id: 1, - permission: GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, + permissions: [PermissionEnum.CREATE_COMMENT, PermissionEnum.CREATE_THREAD, PermissionEnum.CREATE_COMMENT_REACTION,PermissionEnum.CREATE_THREAD_REACTION], }, { id: 2, - permission: GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, + permissions: [PermissionEnum.CREATE_COMMENT, PermissionEnum.CREATE_THREAD, PermissionEnum.CREATE_COMMENT_REACTION,PermissionEnum.CREATE_THREAD_REACTION], }, { id: 3, - permission: GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, + permissions: [PermissionEnum.CREATE_COMMENT, PermissionEnum.CREATE_THREAD, PermissionEnum.CREATE_COMMENT_REACTION,PermissionEnum.CREATE_THREAD_REACTION], }, ]), }), From df9924500f1c0c534c909e790397fb947fe1af7f Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Wed, 16 Oct 2024 00:41:04 +0500 Subject: [PATCH 20/27] Updated thread lifecycle test --- libs/model/test/thread/thread-lifecycle.spec.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/libs/model/test/thread/thread-lifecycle.spec.ts b/libs/model/test/thread/thread-lifecycle.spec.ts index 4d04763e4df..7cb6385a622 100644 --- a/libs/model/test/thread/thread-lifecycle.spec.ts +++ b/libs/model/test/thread/thread-lifecycle.spec.ts @@ -171,13 +171,8 @@ describe('Thread lifecycle', () => { }); await seed('GroupPermission', { group_id: commentGroupId, - allowed_actions: [schemas.PermissionEnum.CREATE_COMMENT], - }); - await seed('GroupTopicPermission', { - group_id: threadGroupId, topic_id: _community?.topics?.[0]?.id || 0, - allowed_actions: - schemas.GroupTopicPermissionEnum.UPVOTE_AND_COMMENT_AND_POST, + allowed_actions: [schemas.PermissionEnum.CREATE_COMMENT], }); community = _community!; From 7852a3eaaf285cc9cdf56f98c7bb2c5bc64e3bac Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Wed, 16 Oct 2024 01:01:42 +0500 Subject: [PATCH 21/27] Create group in bulks --- .../src/community/CreateGroup.command.ts | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/libs/model/src/community/CreateGroup.command.ts b/libs/model/src/community/CreateGroup.command.ts index 514265c9bf4..8f73346811a 100644 --- a/libs/model/src/community/CreateGroup.command.ts +++ b/libs/model/src/community/CreateGroup.command.ts @@ -13,6 +13,8 @@ export const CreateGroupErrors = { InvalidTopics: 'Invalid topics', }; +type GroupsPayload = { group_id: number; topic_id: number; allowed_actions: schemas.PermissionEnum[] }; + export function CreateGroup(): Command< typeof schemas.CreateGroup, AuthContext @@ -72,20 +74,16 @@ export function CreateGroup(): Command< ); // add topic level interaction permissions for current group - await Promise.all( - (payload.topics || [])?.map(async (t) => { - if (group.id) { - await models.GroupPermission.create( - { - group_id: group.id, - topic_id: t.id, - allowed_actions: t.permissions, - }, - { transaction }, - ); - } - }), - ); + const groupPermissions = (payload.topics || []).map((t) => { + if (group.id) { + return { + group_id: group.id, + topic_id: t.id, + allowed_actions: t.permissions, + }; + } + }).filter(Boolean) as GroupsPayload[]; + await models.GroupPermission.bulkCreate(groupPermissions, { transaction }); } return group.toJSON(); }, From 1fb9fa8ded082c17b48d91330f46ac9fbe945c05 Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Wed, 16 Oct 2024 01:06:16 +0500 Subject: [PATCH 22/27] Added back update poll permission --- libs/schemas/src/entities/group-permission.schemas.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/schemas/src/entities/group-permission.schemas.ts b/libs/schemas/src/entities/group-permission.schemas.ts index 5de7d64ddcd..c466c5d489e 100644 --- a/libs/schemas/src/entities/group-permission.schemas.ts +++ b/libs/schemas/src/entities/group-permission.schemas.ts @@ -6,6 +6,7 @@ export enum PermissionEnum { CREATE_COMMENT = 'CREATE_COMMENT', CREATE_THREAD_REACTION = 'CREATE_THREAD_REACTION', CREATE_COMMENT_REACTION = 'CREATE_COMMENT_REACTION', + UPDATE_POLL = 'UPDATE_POLL' } export type GroupPermissionAction = keyof typeof PermissionEnum; From b193e6d303a772670a08c2c194f2c1d9c6582ed9 Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Wed, 16 Oct 2024 01:08:20 +0500 Subject: [PATCH 23/27] Revert bulk group create --- .../src/community/CreateGroup.command.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/libs/model/src/community/CreateGroup.command.ts b/libs/model/src/community/CreateGroup.command.ts index 8f73346811a..514265c9bf4 100644 --- a/libs/model/src/community/CreateGroup.command.ts +++ b/libs/model/src/community/CreateGroup.command.ts @@ -13,8 +13,6 @@ export const CreateGroupErrors = { InvalidTopics: 'Invalid topics', }; -type GroupsPayload = { group_id: number; topic_id: number; allowed_actions: schemas.PermissionEnum[] }; - export function CreateGroup(): Command< typeof schemas.CreateGroup, AuthContext @@ -74,16 +72,20 @@ export function CreateGroup(): Command< ); // add topic level interaction permissions for current group - const groupPermissions = (payload.topics || []).map((t) => { - if (group.id) { - return { - group_id: group.id, - topic_id: t.id, - allowed_actions: t.permissions, - }; - } - }).filter(Boolean) as GroupsPayload[]; - await models.GroupPermission.bulkCreate(groupPermissions, { transaction }); + await Promise.all( + (payload.topics || [])?.map(async (t) => { + if (group.id) { + await models.GroupPermission.create( + { + group_id: group.id, + topic_id: t.id, + allowed_actions: t.permissions, + }, + { transaction }, + ); + } + }), + ); } return group.toJSON(); }, From f1fd855659e480cd18466a2048c91e7c60bde997 Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Wed, 16 Oct 2024 01:13:07 +0500 Subject: [PATCH 24/27] Fix type --- .../scripts/views/pages/discussions/CommentTree/CommentTree.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/CommentTree.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/CommentTree.tsx index 332efefd0b1..296cedcecf3 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/CommentTree.tsx +++ b/packages/commonwealth/client/scripts/views/pages/discussions/CommentTree/CommentTree.tsx @@ -531,7 +531,7 @@ export const CommentTree = ({ rootThread={thread} canComment={canComment} tooltipText={ - !canComment && disabledActionsTooltipText + !canComment && typeof disabledActionsTooltipText === 'string' ? disabledActionsTooltipText : '' } From af8921497af05d0b59a7bd73f1842cc130560572 Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Wed, 16 Oct 2024 01:17:41 +0500 Subject: [PATCH 25/27] Fix model test --- libs/model/test/thread/thread-lifecycle.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/model/test/thread/thread-lifecycle.spec.ts b/libs/model/test/thread/thread-lifecycle.spec.ts index 7cb6385a622..8efbe52a47a 100644 --- a/libs/model/test/thread/thread-lifecycle.spec.ts +++ b/libs/model/test/thread/thread-lifecycle.spec.ts @@ -163,6 +163,7 @@ describe('Thread lifecycle', () => { }); await seed('GroupPermission', { group_id: threadGroupId, + topic_id: _community?.topics?.[0]?.id || 0, allowed_actions: [ schemas.PermissionEnum.CREATE_THREAD, schemas.PermissionEnum.CREATE_THREAD_REACTION, From 7fcc8157f44befb51b01b44bcdc8236b92cf75de Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Wed, 16 Oct 2024 15:24:38 +0500 Subject: [PATCH 26/27] Fix migration --- ...d-topic_id-for-each-group_id-in-GroupPermissions.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/commonwealth/server/migrations/20241015145424-add-topic_id-for-each-group_id-in-GroupPermissions.js b/packages/commonwealth/server/migrations/20241015145424-add-topic_id-for-each-group_id-in-GroupPermissions.js index 1ce2fbb5bff..84591ed65a0 100644 --- a/packages/commonwealth/server/migrations/20241015145424-add-topic_id-for-each-group_id-in-GroupPermissions.js +++ b/packages/commonwealth/server/migrations/20241015145424-add-topic_id-for-each-group_id-in-GroupPermissions.js @@ -3,6 +3,12 @@ module.exports = { up: async (queryInterface, Sequelize) => { return queryInterface.sequelize.transaction(async (t) => { + await queryInterface.removeConstraint( + 'GroupPermissions', + 'GroupPermissions_pkey', + { transaction: t }, + ); + await queryInterface.removeConstraint( 'GroupPermissions', 'GroupPermissions_group_id_fkey', @@ -38,9 +44,9 @@ module.exports = { ); await queryInterface.addConstraint('GroupPermissions', { - type: 'unique', + type: 'primary key', fields: ['group_id', 'topic_id'], - name: 'GroupPermissions_unique_composite_constraint', + name: 'GroupPermissions_pkey', transaction: t, }); }); From a2009afa511a6c9057444ca65496f7a3ae920b74 Mon Sep 17 00:00:00 2001 From: Malik Zulqurnain Date: Thu, 17 Oct 2024 18:28:12 +0500 Subject: [PATCH 27/27] Added bulk create for group topic permissions --- .../src/community/CreateGroup.command.ts | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/libs/model/src/community/CreateGroup.command.ts b/libs/model/src/community/CreateGroup.command.ts index 514265c9bf4..947eb6c3f77 100644 --- a/libs/model/src/community/CreateGroup.command.ts +++ b/libs/model/src/community/CreateGroup.command.ts @@ -71,21 +71,19 @@ export function CreateGroup(): Command< }, ); - // add topic level interaction permissions for current group - await Promise.all( - (payload.topics || [])?.map(async (t) => { - if (group.id) { - await models.GroupPermission.create( - { - group_id: group.id, - topic_id: t.id, - allowed_actions: t.permissions, - }, - { transaction }, - ); - } - }), - ); + if (group.id) { + // add topic level interaction permissions for current group + const groupPermissions = (payload.topics || []).map((t) => ({ + group_id: group.id!, + topic_id: t.id, + allowed_actions: sequelize.literal( + `ARRAY[${t.permissions.map((p) => `'${p}'`).join(', ')}]::"enum_GroupPermissions_allowed_actions"[]`, + ) as unknown as schemas.PermissionEnum[], + })); + await models.GroupPermission.bulkCreate(groupPermissions, { + transaction, + }); + } } return group.toJSON(); },